mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
### 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
144 lines
3.4 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|