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:
parent
5fd0f38de9
commit
c9321c72c9
15 changed files with 182 additions and 15 deletions
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
19
test/e2e/app-dir/parallel-routes-layouts/app/layout.tsx
Normal file
19
test/e2e/app-dir/parallel-routes-layouts/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Default() {
|
||||
return '@bar default'
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export default function Layout({ children }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>@bar Layout</h1>
|
||||
<div id="bar-children">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div>Bar Slot</div>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div>Subroute</div>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Default() {
|
||||
return '@foo default'
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export default function Layout({ children }) {
|
||||
return (
|
||||
<div>
|
||||
<h1>@foo Layout</h1>
|
||||
<div id="foo-children">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div>Foo Slot</div>
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return 'default page'
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function Page() {
|
||||
return <div>Hello from Nested</div>
|
||||
}
|
3
test/e2e/app-dir/parallel-routes-layouts/app/page.tsx
Normal file
3
test/e2e/app-dir/parallel-routes-layouts/app/page.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default async function Home() {
|
||||
return <div>Hello World</div>
|
||||
}
|
6
test/e2e/app-dir/parallel-routes-layouts/next.config.js
Normal file
6
test/e2e/app-dir/parallel-routes-layouts/next.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* @type {import('next').NextConfig}
|
||||
*/
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
|
@ -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()))
|
||||
}
|
Loading…
Reference in a new issue