Skip to content

Commit

Permalink
fix intercepting route behavior in route groups
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Dec 19, 2023
1 parent 25e0988 commit 9ea6031
Show file tree
Hide file tree
Showing 14 changed files with 263 additions and 4 deletions.
23 changes: 19 additions & 4 deletions packages/next/src/build/webpack/loaders/next-app-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
PAGE_SEGMENT_KEY,
} from '../../../shared/lib/segment'
import { getFilesInDir } from '../../../lib/get-files-in-dir'
import { normalizeAppPath } from '../../../shared/lib/router/utils/app-paths'

export type AppLoaderOptions = {
name: string
Expand Down Expand Up @@ -422,10 +423,24 @@ async function createTreeCodeFromPath(
if (!props[normalizeParallelKey(adjacentParallelSegment)]) {
const actualSegment =
adjacentParallelSegment === 'children' ? '' : adjacentParallelSegment
const defaultPath =
(await resolver(
`${appDirPrefix}${segmentPath}/${actualSegment}/default`
)) ?? 'next/dist/client/components/parallel-route-default'
const fallbackDefault =
'next/dist/client/components/parallel-route-default'
let defaultPath = await resolver(
`${appDirPrefix}${segmentPath}/${actualSegment}/default`
)

if (!defaultPath) {
// no default was found at this segment. Check if the normalized segment resolves a default
// for example: /(level1)/(level2)/default doesn't exist, but /default does
const normalizedDefault = await resolver(
`${appDirPrefix}${normalizeAppPath(
segmentPath
)}/${actualSegment}/default`
)

// if a default is found, use that. Otherwise use the fallback, which will trigger a `notFound()`
defaultPath = normalizedDefault ?? fallbackDefault
}

props[normalizeParallelKey(adjacentParallelSegment)] = `[
'${DEFAULT_SEGMENT_KEY}',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react'

export default function Page({ params }: { params: { id: string } }) {
return <div>Intercepted Photo Page {params.id}</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return <div>@slot default</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function Layout(props: {
children: React.ReactNode
slot: React.ReactNode
}) {
return (
<div>
<div id="children">{props.children}</div>
<div id="slot">{props.slot}</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Link from 'next/link'

export default function Page({ params }: { params: { id: string } }) {
return (
<div>
Photo Page (non-intercepted) {params.id} <Link href="/">Back Home</Link>
</div>
)
}
9 changes: 9 additions & 0 deletions test/e2e/app-dir/interception-route-groups/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function Layout(props: { children: React.ReactNode }) {
return (
<html>
<body>
<div id="children">{props.children}</div>
</body>
</html>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import React from 'react'

export default function Page({ params }: { params: { id: string } }) {
return <div>Intercepted Photo Page {params.id}</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <div>@intercepted default</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function Layout(props: {
children: React.ReactNode
intercepted: React.ReactNode
}) {
return (
<div>
<div id="children">{props.children}</div>
<div id="slot">{props.intercepted}</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page({ params }: { params: { id: string } }) {
return (
<div>
Photo Page (non-intercepted) {params.id}{' '}
<Link href="/nested">Back to /nested</Link>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Default() {
return <div>Default Children (nested)</div>
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/interception-route-groups/app/nested/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Link from 'next/link'

export default function Page() {
return (
<div>
<Link href="/nested/photos/1">Photo 1</Link>{' '}
<Link href="/nested/photos/2">Photo 2</Link>
</div>
)
}
12 changes: 12 additions & 0 deletions test/e2e/app-dir/interception-route-groups/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Link from 'next/link'

export default function Page() {
return (
<div>
<Link href="/photos/1">Photo 1</Link>{' '}
<Link href="/photos/2">Photo 2</Link>
<hr />
<Link href="/nested">To /nested</Link>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { createNextDescribe } from 'e2e-utils'
import { check } from 'next-test-utils'
import { FileRef } from 'e2e-utils'
import path from 'path'

createNextDescribe(
'interception route groups (with default)',
{
files: {
app: new FileRef(path.join(__dirname, 'app')),
'app/default.tsx': `
export default function Default() {
return <div>Default Children (Root)</div>
}
`,
},
},
({ next }) => {
it("should render the root default when a route group doesn't have a default", async () => {
const browser = await next.browser('/')

await browser.elementByCss('[href="/photos/1"]').click()
// this route was intercepted, so we should see the slot contain the page content
await check(
() => browser.elementById('slot').text(),
/Intercepted Photo Page 1/
)

// and the children slot should be whatever is specified by default (in this case, default is defined at the root of the app)
await check(
() => browser.elementById('children').text(),
/Default Children \(Root\)/
)

await browser.refresh()

// once we reload, the route is no longer intercepted. The slot will fallback to the default
// and the children slot will be whatever is specified by the corresponding page component
await check(() => browser.elementById('slot').text(), /@slot default/)
await check(
() => browser.elementById('children').text(),
/Photo Page \(non-intercepted\) 1/
)

await browser.elementByCss('[href="/"]').click()

// perform the same checks as above, but with the other page
await browser.elementByCss('[href="/photos/2"]').click()
await check(
() => browser.elementById('slot').text(),
/Intercepted Photo Page 2/
)
await check(
() => browser.elementById('children').text(),
/Default Children \(Root\)/
)

await browser.refresh()

await check(() => browser.elementById('slot').text(), /@slot default/)
await check(
() => browser.elementById('children').text(),
/Photo Page \(non-intercepted\) 2/
)
})

it('should work when nested a level deeper', async () => {
const browser = await next.browser('/nested')
await browser.elementByCss('[href="/nested/photos/1"]').click()

// this route was intercepted, so we should see the slot contain the page content
await check(
() => browser.elementById('slot').text(),
/Intercepted Photo Page 1/
)

// and the children slot should be whatever is specified by default (in this case, default is defined at `/nested/default`)
await check(
() => browser.elementById('children').text(),
/Default Children \(nested\)/
)

await browser.refresh()

// once we reload, the route is no longer intercepted. The slot will fallback to the default
// and the children slot will be whatever is specified by the corresponding page component
await check(
() => browser.elementById('slot').text(),
/@intercepted default/
)
await check(
() => browser.elementById('children').text(),
/Photo Page \(non-intercepted\) 1/
)

await browser.elementByCss('[href="/nested"]').click()

// perform the same checks as above, but with the other page
await browser.elementByCss('[href="/nested/photos/2"]').click()
await check(
() => browser.elementById('slot').text(),
/Intercepted Photo Page 2/
)
await check(
() => browser.elementById('children').text(),
/Default Children \(nested\)/
)

await browser.refresh()

await check(
() => browser.elementById('slot').text(),
/@intercepted default/
)
await check(
() => browser.elementById('children').text(),
/Photo Page \(non-intercepted\) 2/
)
})
}
)

createNextDescribe(
'interception route groups (no default)',
{
files: {
app: new FileRef(path.join(__dirname, 'app')),
},
},
({ next, isNextStart }) => {
if (process.env.__NEXT_EXPERIMENTAL_PPR === 'true' && isNextStart) {
// The PPR prefetch will 404 since it'll request the full page (which won't exist, since the intercepted route
// has no default). The default router behavior if a prefetch fails is to trigger an MPA navigation
it('should render the non-intercepted page on navigation', async () => {
const browser = await next.browser('/')

await browser.elementByCss('[href="/photos/1"]').click()
await check(() => browser.elementById('slot').text(), '')
await check(
() => browser.elementById('children').text(),
/Photo Page \(non-intercepted\) 1/
)
})
} else {
it('should use the default fallback (a 404) if there is no custom default page', async () => {
const browser = await next.browser('/')

await browser.elementByCss('[href="/photos/1"]').click()
await check(() => browser.elementByCss('body').text(), /404/)
})
}
}
)

0 comments on commit 9ea6031

Please sign in to comment.