parallel routes: support multi-slot layouts (#61115)

### What?
When using layouts in multiple parallel route slots, only 1 of the
layouts would render.

### Why?
The `resolveParallelSegments` logic responsible for populating the
loader tree was incorrectly bailing if it found another parallel route
that matched a page component.

### How?
I did my best to update this loader code with some more comments to make
it a bit easier to reason about, and also made some slight refactors.
But the gist of the fix is just ensuring that each parallel route (that
isn't a direct match on the `children` slot) is resolved as an array, so
that when the subtree is created, it doesn't skip over the slot.

Fixes #58506
Fixes #59463

Closes NEXT-2222
This commit is contained in:
Zack Tanner 2024-01-26 11:46:23 -08:00 committed by GitHub
parent 5fd0f38de9
commit c9321c72c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 182 additions and 15 deletions

View file

@ -272,6 +272,8 @@ async function createTreeCodeFromPath(
}
for (const [parallelKey, parallelSegment] of parallelSegments) {
// if parallelSegment is the page segment (ie, `page$` and not ['page$']), it gets loaded into the __PAGE__ slot
// as it's the page for the current route.
if (parallelSegment === PAGE_SEGMENT) {
const matchedPagePath = `${appDirPrefix}${segmentPath}${
parallelKey === 'children' ? '' : `/${parallelKey}`
@ -293,27 +295,37 @@ async function createTreeCodeFromPath(
continue
}
// if the parallelSegment was not matched to the __PAGE__ slot, then it's a parallel route at this level.
// the code below recursively traverses the parallel slots directory to match the corresponding __PAGE__ for each parallel slot
// while also filling in layout/default/etc files into the loader tree at each segment level.
const subSegmentPath = [...segments]
if (parallelKey !== 'children') {
// A `children` parallel key should have already been processed in the above segment
// So we exclude it when constructing the subsegment path for the remaining segment levels
subSegmentPath.push(parallelKey)
}
const normalizedParallelSegments = Array.isArray(parallelSegment)
? parallelSegment.slice(0, 1)
: [parallelSegment]
const normalizedParallelSegment = Array.isArray(parallelSegment)
? parallelSegment[0]
: parallelSegment
subSegmentPath.push(
...normalizedParallelSegments.filter(
(segment) =>
segment !== PAGE_SEGMENT && segment !== PARALLEL_CHILDREN_SEGMENT
)
)
if (
normalizedParallelSegment !== PAGE_SEGMENT &&
normalizedParallelSegment !== PARALLEL_CHILDREN_SEGMENT
) {
// If we don't have a page segment, nor a special $children marker, it means we need to traverse the next directory
// (ie, `normalizedParallelSegment` would correspond with the folder that contains the next level of pages/layout/etc)
// we push it to the subSegmentPath so that we can fill in the loader tree for that segment.
subSegmentPath.push(normalizedParallelSegment)
}
const { treeCode: pageSubtreeCode } =
await createSubtreePropsFromSegmentPath(subSegmentPath)
const parallelSegmentPath = subSegmentPath.join('/')
// Fill in the loader tree for all of the special files types (layout, default, etc) at this level
// `page` is not included here as it's added above.
const filePaths = await Promise.all(
Object.values(FILE_TYPES).map(async (file) => {
@ -534,14 +546,15 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
const isParallelRoute = rest[0].startsWith('@')
if (isParallelRoute) {
if (rest.length === 2 && rest[1] === 'page') {
// matched will be an empty object in case the parallel route is at a path with no existing page
// in which case, we need to mark it as a regular page segment
matched[rest[0]] = Object.keys(matched).length
? [PAGE_SEGMENT]
: PAGE_SEGMENT
// We found a parallel route at this level. We don't want to mark it explicitly as the page segment,
// as that should be matched to the `children` slot. Instead, we use an array, to signal to `createSubtreePropsFromSegmentPath`
// that it needs to recursively fill in the loader tree code for the parallel route at the appropriate levels.
matched[rest[0]] = [PAGE_SEGMENT]
continue
}
// we insert a special marker in order to also process layout/etc files at the slot level
// If it was a parallel route but we weren't able to find the page segment (ie, maybe the page is nested further)
// we first insert a special marker to ensure that we still process layout/default/etc at the slot level prior to continuing
// on to the page segment.
matched[rest[0]] = [PARALLEL_CHILDREN_SEGMENT, ...rest.slice(1)]
continue
}
@ -573,6 +586,7 @@ const nextAppLoader: AppLoader = async function nextAppLoader() {
matched.children = rest[0]
}
}
return Object.entries(matched)
}

View file

@ -0,0 +1,19 @@
import Link from 'next/link'
import React from 'react'
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<div>
<Link href="/nested">to nested</Link>
</div>
<div>
<Link href="/nested/subroute">to nested subroute</Link>
</div>
<h1>Root Layout</h1>
<div>{children}</div>
</body>
</html>
)
}

View file

@ -0,0 +1,3 @@
export default function Default() {
return '@bar default'
}

View file

@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<div>
<h1>@bar Layout</h1>
<div id="bar-children">{children}</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Bar Slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Subroute</div>
}

View file

@ -0,0 +1,3 @@
export default function Default() {
return '@foo default'
}

View file

@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<div>
<h1>@foo Layout</h1>
<div id="foo-children">{children}</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Foo Slot</div>
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return 'default page'
}

View file

@ -0,0 +1,10 @@
export default function Layout({ children, bar, foo }) {
return (
<div>
<h1>Nested Layout</h1>
<div id="nested-children">{children}</div>
<div id="foo-slot">{foo}</div>
<div id="bar-slot">{bar}</div>
</div>
)
}

View file

@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello from Nested</div>
}

View file

@ -0,0 +1,3 @@
export default async function Home() {
return <div>Hello World</div>
}

View file

@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig

View file

@ -0,0 +1,78 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('parallel-routes-layouts', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should properly render layouts for multiple slots', async () => {
const browser = await next.browser('/nested')
let layouts = await getLayoutHeadings(browser)
expect(layouts).toHaveLength(4)
expect(layouts).toEqual(
expect.arrayContaining([
'Root Layout',
'Nested Layout',
'@foo Layout',
'@bar Layout',
])
)
// ensure nested/page is showing its contents
expect(await browser.elementById('nested-children').text()).toBe(
'Hello from Nested'
)
// Ensure each slot is showing its contents
expect(await browser.elementById('foo-children').text()).toBe('Foo Slot')
expect(await browser.elementById('bar-children').text()).toBe('Bar Slot')
// Navigate to a subroute that only has a match for the @foo slot
await browser.elementByCss('[href="/nested/subroute"]').click()
await retry(async () => {
// the bar slot has a match for the subroute, so we expect it to be rendered
expect(await browser.elementById('bar-children').text()).toBe('Subroute')
// We still expect the previous active slots to be visible until reload even if they don't match
layouts = await getLayoutHeadings(browser)
expect(layouts).toEqual(
expect.arrayContaining([
'Root Layout',
'Nested Layout',
'@foo Layout',
'@bar Layout',
])
)
expect(await browser.elementById('foo-children').text()).toBe('Foo Slot')
expect(await browser.elementById('nested-children').text()).toBe(
'Hello from Nested'
)
})
// Trigger a reload, which will clear the previous active slots and show the ones that explicitly have matched
await browser.refresh()
layouts = await getLayoutHeadings(browser)
// the foo slot does not match on the subroute, so we don't expect the layout or page to be rendered
expect(layouts).toHaveLength(3)
expect(layouts).toEqual(
expect.arrayContaining(['Root Layout', 'Nested Layout', '@bar Layout'])
)
// we should now see defaults being rendered for both the page & foo slots
expect(await browser.elementById('nested-children').text()).toBe(
'default page'
)
expect(await browser.elementById('foo-slot').text()).toBe('@foo default')
})
})
async function getLayoutHeadings(browser): Promise<string[]> {
const elements = await browser.elementsByCss('h1')
return Promise.all(elements.map(async (el) => await el.innerText()))
}