vercel / next.js

The React Framework
https://nextjs.org
MIT License
127.51k stars 27.04k forks source link

Next13.4 appDir does not render layout and template in the way the docs say it should #49596

Open zackdotcomputer opened 1 year ago

zackdotcomputer commented 1 year ago

Verify canary release

Provide environment information

Operating System:
      Platform: darwin
      Arch: arm64
      Version: Darwin Kernel Version 22.4.0: Mon Mar  6 20:59:58 PST 2023; root:xnu-8796.101.5~3/RELEASE_ARM64_T6020
    Binaries:
      Node: 18.16.0
      npm: 9.5.1
      Yarn: 1.22.19
      pnpm: N/A
    Relevant packages:
      next: 13.4.2-canary.4
      eslint-config-next: 13.4.1
      react: 18.2.0
      react-dom: 18.2.0
      typescript: 5.0.4

Which area(s) of Next.js are affected? (leave empty if unsure)

App directory (appDir: true), Routing (next/router, next/navigation, next/link)

Link to the code that reproduces this issue

https://codesandbox.io/p/sandbox/wizardly-wing-fj1kod

To Reproduce

Download and run the files in the sandbox (so that you can attach the React Developer Tools)

Note that in the react developer tools, the following component tree is rendered, showing the top Suspense added by the layout.tsx, two Next internal components, and then the Suspense from the template.tsx: Screenshot 2023-05-10 at 12 21 48

Describe the Bug

The NextJS Documentation on templates in the app-directory router here says that layouts and templates will be rendered as such:

<Layout>
  {/* Note that the template is given a unique key. */}
  <Template key={routeParam}>{children}</Template>
</Layout>

However, in practice we can clearly see that NextJS inserts a component called OuterLayoutRouter between each layout-template pair, separating the layout from the part of the Template framework that receives the unique key promised by the docs.

This means that components that rely on detecting a change of key of their direct child cannot function in the layout-template lifecycle. In practice this leads to issues primarily with animation libraries, such as #49279.

At its core, though, I think the bug here is that the documentation and the behavior do not align with each other.

Expected Behavior

This could be fixed in one of two ways:

  1. The documentation could be updated to remove the claim that the direct child of the Layout will be a value with a unique per-route key, and the documentation could remove the promise that Templates can be used to perform on-enter and on-exit animations. I think this would be a subpar solution but it would restore consistency between the docs and deployed behavior.
  2. The OuterLayoutRouter in layout-router.tsx could be updated so that it receives the layout.tsx component from the app-render loop and embeds that layout in its returned JSX tree, which would allow the layout and template to truly be parent-child. I think this would be preferable but I'm also still wrapping my head around how the new app render loop works so I don't yet know what would might make this difficult.

Which browser are you using? (if relevant)

Chrome but not relevant

How are you deploying your application? (if relevant)

Not relevant

NEXT-1380

haaarshsingh commented 1 year ago

+1. I've been dealing with this for months now.

d9j commented 1 year ago

dont wanna be critical but now I see that introducing appdir thing showing hows flawed the architecture of nextjs . such minor feature as option to use appdir fiolder causing so many problems.

imyuanli commented 1 year ago

I don't know why Next.js needs to be changed to this way

LotharVM commented 1 year ago

@timneutkens any chance someone can look into this, or a status update? It's currently blocking the usage of exit animations with framer motion that I'd loveee to use in a website I'm working on right now.

fabiodinota commented 1 year ago

Would love if someone could look into this ^

evankirkiles commented 1 year ago

EDIT: Moved the below into a discussion here: https://github.com/vercel/next.js/discussions/56594.

With the (IMO well-thought-out) way the app router handles templates and layouts, this is a bit finicky to implement. I'm just looking at the source using my own knowledge so please keep that disclaimer in mind, but here goes.

As outlined above, to persist a route after its lifetime (which is key for exit transitions), we need to somehow bridge the gap between a persisted component a user can manage (where one could place Framer motion's <AnimatePresence />, for example) and an unpersisted (mounts / unmounts when its route is active / inactive) internal Next.js component keyed based on the route segment it is handling. This is what template was supposed to be from the old docs, but with the necessities of improved caching of route subsegments there is necessarily an intermediary component to handle loading / unloading those templates. This is what OuterLayoutRouter is. With regards to this component, there are two things in mind:


Firstly, for a single layout with n parallel routes, there will be n OuterLayoutRouters throughout its child DOM. This one-to-many relationship is intrinsic, so we couldn't just pop the entire layout.tsx into the OuterLayoutRouter to split the gap as suggested in the above issue.

Secondly, the OuterLayerRouter itself is only mounted once with the layout, and changes its contents based on a context (not props), so we can't try to animate the presence of that in any way, even if we were to add a key to it.

Thirdly, while currently each OuterLayoutRouter only currently maps to a single TemplateContext.Provider at a time with a changing key, the introduction of <Offscreen /> in React 18 will allow for concurrent background rendering of multiple of these TemplateContext.Provider components at a time (multiple in the tree, though only one being visible), which any route transition implementation will need to be aware of.

Internally, the DOM created looks like this (as illustrated in the issue itself):

<...USER.Layout >  {/* This is your custom layout  */}\
   {/* Some user components  */}
  <OuterLayoutRouter> {/* The OuterLayoutRouter for the parallel route  */}
    {styles /* If the root layout, e.g. with <html>, other styles go here */}
    {preservedSegments.map(() => (  {/* As of Next.js 15.4.X, only one segment is preserved at a time.  */}
       <TemplateContext.Provider key="....">
         <USER.Template>
           <RenderTemplateContextValues> {/* This renders the contents inside the template */}
         </USER.Template >
       </TemplateContext.Provider>
    )}
  </OuterLayoutRouter>
  {/* Maybe some more user components and parallel routes w/ their own OuterLayoutRouters  */}
</...USER.Layout >

As stated before, what we need is to be able to insert a User-defined component wrapping the preservedSegments, which are keyed correctly—animatable using something like <AnimatePresence />—as they have a prop-based state and so can preserve their visual appearance throughout the duration of the exit animation.

My Solution

Using the above information, a super quick way to implement such exit transition behavior is to create another Next.js file-based component. For my purposes, I've called this Glue, locatable in glue.tsx just like layout.tsx or template.tsx. Then, for any route, you can define this intermediary, persisted "glue" component which is placed in between the layout and any sub-routes to give a DOM structure like this:

<...USER.Layout > 
   {/* Some user components  */}
  <OuterLayoutRouter>
    {styles}
    <USER.Glue > {/* <--- Placement of new "Glue" component  */}
       {preservedSegments.map(() => 
          <TemplateContext.Provider key="....">
            <USER.Template>
              <RenderTemplateContextValues>
            </USER.Template >
          </TemplateContext.Provider>
       )}
    </USER.Glue >
  </OuterLayoutRouter>
  {/* More user components  */}
</...USER.Layout >

An example Glue component with FramerMotion would look like:

// app/[segment]/glue.tsx
'use client'
import React from 'react'
import { AnimatePresence } from 'framer-motion';
import { PropsWithChildren } from 'react'

export default function Glue({ children }: PropsWithChildren) {
  return (
    <AnimatePresence initial={false} mode="wait">
      {children}
    </AnimatePresence>
  )
}

With a corresponding template providing the motion.div:

// app/[segment]/template.tsx
"use client";
import { motion } from "framer-motion";
import React from "react";
import { PropsWithChildren } from "react";

export default function Template({ children }: PropsWithChildren) {
  return (
    <motion.div
      initial={{ opacity: 0, dur: 1000 }}
      animate={{ opacity: 1, dur: 1000 }}
      exit={{ opacity: 0, dur: 1000 }}
    >
      {children}
    </motion.div>
  )
}

Because of how Next parses OuterLayoutRouters from your file tree, with this approach Glue components have the same precedence and rules as Templates, meaning a root glue component will apply to immediate child layouts from the root layout.

Making the above changes gives the following results:

https://github.com/vercel/next.js/assets/30581915/92c83d1b-599f-4a9b-9f82-22a599f1e7d8

(Note how the sidebar parallel route also receives exit transitions—though, confusingly, the transition occurs only when moving from the root @sidebar/page.tsx (where the TemplateContext.Provider key is page$) to any sub-path in @sidebar/[segment]/page.tsx (where the TemplateContext.Provider key is children, and routing happens further down the tree. This doesn't happen for the implicit @children slot, where the root page is keyed with __PAGE__ and following segments are keyed with segment| {whichever segment you navigated to} | c.)

The folder structure of the above example looks like this:

image

Branch

The branch / draft PR with the above changes adding a Glue component can be found at https://github.com/vercel/next.js/pull/56591.

More

I think I speak for a lot of web designers / developers when I say that this is probably one of the largest issues keeping me on the pages directory—the ability to use route transitions for a smoother user experience. It would be a massive boon for the appDir to have this functionality implemented.