Files
Wyatt JohnsonandGitHub 43c6ccfdd5 Fix parallel routes ignoring generateStaticParams from primary route (#84889)
### What?

Fixes a bug where `generateStaticParams` from the primary route was
being ignored when dynamic segments existed in both regular routes
(`children`) and parallel routes (e.g., `@breadcrumbs`), causing routes
that should have been statically generated to return 404 errors.

Also fixes a related redirect loop issue that occurred when serving 404
pages with RSC request header validation enabled.

### Why?

**Primary Issue: Segment Deduplication**

The `collectAppPageSegments` function had two code paths for
deduplicating segments when the same dynamic parameter (like `[slug]`)
appeared in both the primary route tree and parallel routes:

1. **Individual segment processing** - Correctly preferred non-parallel
segments
2. **Batch processing at page boundaries** - Blindly overwrote existing
segments without preference checking

Due to BFS traversal order, parallel route segments would overwrite the
correct `children` route segments during batch processing. This marked
parameters as `isParallelRouteSegment: true`, which:
- Prevented the build system from recognizing them as regular route
parameters
- Caused `generateStaticParams` to be ignored during prerendering
- Resulted in all routes being treated as dynamic-only (404s with
`dynamicParams = false`)

This violated the architectural invariant that parallel routes are
"secondary" to the main route hierarchy - the primary route tree should
determine URL structure and static generation behavior.

**Secondary Issue: 404 Page Redirect Loop**

When serving a 404 page (NoFallbackError), the experimental RSC request
header validation would cause an infinite redirect loop. This happened
because:
- Headers are already stripped when serving the 404 page
- The validation comparison would always fail
- This triggered a redirect, which would hit the same 404, creating a
loop

### How?

**Segment Preference Fix (`app-segments.ts`):**

Applied consistent segment preference logic to both deduplication code
paths in `collectAppPageSegments` and `collectFallbackRouteParams`,
ensuring segments from the `children` route are always preferred over
parallel route segments. This preserves the following guarantees:

1. **URL structure is determined by the primary route tree** - Parallel
routes derive their parameters from `children`, not vice versa
2. **`generateStaticParams` works correctly** - Static generation is
controlled by the primary route's configuration
3. **Parallel routes remain decorative** - They enhance the page without
affecting core routing behavior

**Redirect Loop Fix (`base-server.ts`):**

Skip RSC request header validation when serving 404 pages (`!is404Page`
condition), preventing the redirect loop caused by already-stripped
headers failing the validation check.

**Test Coverage:**

Added comprehensive e2e test suite covering:
- Root parameters with `generateStaticParams` and `dynamicParams =
false`
- Dynamic child routes with and without `generateStaticParams`
- Parallel route slots with shared dynamic segments
- Prefetch behavior to ensure no redirect loops during preloading

Fixes #

NAR-446
2025-10-15 17:02:57 -06:00

144 lines
3.4 KiB
TypeScript

'use client'
import Link from 'next/link'
import { useState } from 'react'
type RouteInfo = {
href: string
status: '200' | '404'
}
type RouteSection = {
title: string
columns: string[]
routes: RouteInfo[]
}
const ROUTE_SECTIONS: RouteSection[] = [
{
title: 'Base Routes',
columns: ['Route', 'Expected Status'],
routes: [
{ href: '/en', status: '200' },
{ href: '/fr', status: '200' },
{ href: '/es', status: '404' },
],
},
{
title: 'Without generateStaticParams',
columns: ['Route', 'Expected Status'],
routes: [
{ href: '/en/no-gsp/stories/dynamic-123', status: '200' },
{ href: '/fr/no-gsp/stories/dynamic-123', status: '200' },
{ href: '/es/no-gsp/stories/dynamic-123', status: '200' },
],
},
{
title: 'With generateStaticParams',
columns: ['Route', 'Expected Status'],
routes: [
{
href: '/en/gsp/stories/static-123',
status: '200',
},
{
href: '/fr/gsp/stories/static-123',
status: '200',
},
{
href: '/es/gsp/stories/static-123',
status: '404',
},
{
href: '/en/gsp/stories/dynamic-123',
status: '404',
},
{
href: '/fr/gsp/stories/dynamic-123',
status: '404',
},
{
href: '/es/gsp/stories/dynamic-123',
status: '404',
},
],
},
]
function NavLink({ href, status }: { href: string; status: '200' | '404' }) {
return (
<Link
href={href}
className={`font-mono text-xs hover:underline ${status === '200' ? 'text-green-700' : 'text-red-700'}`}
>
{href}
</Link>
)
}
function StatusBadge({ status }: { status: '200' | '404' }) {
return (
<span
className={`inline-block px-1 py-0 rounded text-xs ${status === '200' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{status}
</span>
)
}
function RouteTable({ section }: { section: RouteSection }) {
return (
<div>
<h2 className="text-sm font-bold mb-1">{section.title}</h2>
<table className="w-full border-collapse border border-gray-300 text-xs">
<thead>
<tr className="bg-gray-100">
{section.columns.map((column) => (
<th
key={column}
className="border border-gray-300 px-2 py-0.5 text-left w-1/2"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{section.routes.map((route) => (
<tr key={route.href}>
<td className="border border-gray-300 px-2 py-0.5">
<NavLink href={route.href} status={route.status} />
</td>
<td className="border border-gray-300 px-2 py-0.5">
<StatusBadge status={route.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default function Navigation() {
const [revealed, setRevealed] = useState(false)
return (
<>
<input
id="reveal"
type="checkbox"
checked={revealed}
onChange={() => setRevealed(!revealed)}
/>
{revealed && (
<nav id="nav" className="space-y-2">
{ROUTE_SECTIONS.map((section) => (
<RouteTable key={section.title} section={section} />
))}
</nav>
)}
</>
)
}