fix(prerender): render layout-only parallel slot routes#1091
Conversation
Layout-only App Router routes with parallel slot content were omitted from prerender because the collection loop treated route.pagePath as the only renderable UI entry. That silently skipped static export output for routes whose visible content comes from slot page or default modules. Use a shared App Router render-entry helper for prerender and build-report classification. It keeps route handlers API-only, preserves normal children page priority, and falls back to parallel slot page/default modules for layout-only UI routes. Covers the regression with prerender output assertions for /parallel-nested/home, /parallel-nested/home/nested, and /slot-collision, plus build-report classification coverage.
commit: |
|
/bigbonk review |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review |
There was a problem hiding this comment.
Clean, well-scoped fix. The shared getAppRouteRenderEntryPath() helper correctly unifies the render-entry contract for build-time consumers, and both call sites (prerender collection + build-report classification) now agree on whether a route is renderable.
The logic is sound:
pagePath(children page) remains the primary entry — correct- Route handlers without UI return null — correct
- Fallback to parallel slot
pagePath, thendefaultPath— matches how the App Router runtime resolves visible content for layout-only segments
The type narrowing via Pick<AppRoute, "pagePath" | "routePath" | "parallelSlots"> is the right call — keeps the helper testable and decoupled from the full AppRoute shape.
Tests cover all three fixture scenarios (slot page, nested slot page, default-only slot) for both prerender and build-report classification.
Two minor observations below, neither blocking.
| if (route.pagePath) return route.pagePath; | ||
| if (route.routePath) return null; | ||
|
|
||
| for (const slot of route.parallelSlots) { |
There was a problem hiding this comment.
Nit: the iteration order over parallelSlots determines which slot's file is used for classification. If a route has multiple parallel slots with different segment configs (e.g., one slot page has force-dynamic and another is static), only the first one's config is read.
This is fine for now — it matches the prerender's needs ("is there any renderable UI?") and the classification result is speculative anyway. But a brief code comment noting the first-wins semantics would help future readers not assume it scans all slots.
| for (const route of options.appRoutes ?? []) { | ||
| const { type, revalidate } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic); | ||
| const renderEntryPath = getAppRouteRenderEntryPath(route); | ||
| const { type, revalidate } = classifyAppRoute( |
There was a problem hiding this comment.
Now that classifyAppRoute's first argument can be a slot default.tsx (not just a page.tsx), the @param pagePath doc on classifyAppRoute (line 725) is slightly stale — it says "Absolute path to the page.tsx". Consider updating it to reflect the broader contract in a follow-up, e.g.:
@param pagePath Absolute path to the render entry file (page.tsx, slot page, or slot default; null for API-only routes)
|
Review submitted. Here's a summary of what I reviewed: Approved with two minor non-blocking observations:
The core logic is sound — the |
What this changes
Layout-only App Router routes that render through parallel slot content now participate in prerender and static export. Routes such as
/parallel-nested/home,/parallel-nested/home/nested, and/slot-collisionare rendered instead of being silently omitted fromvinext-prerender.jsonand exported output.Why
The prerender loop used
route.pagePathas the only signal that an App Router route had renderable UI. That is too narrow for parallel routes: a segment can havelayout.tsxwith nopage.tsx, while its visible content is supplied by a parallel slot page or default module.Next.js models this differently:
normalizeAppPath()ignores@slotpath segments when deriving the request pathgetLayoutOrPageModule()can load adefaultPageas the page module for the default segmentparallel-routes-and-interception.test.tsApproach
Add a shared
getAppRouteRenderEntryPath()helper that defines the App Router render-entry contract for build-time consumers:page.tsxremains the primary render entrypagePath, then the first slotdefaultPathBoth prerender collection and build-report classification use that helper, so route reporting and static generation agree on whether a route is renderable.
Validation
vp test run tests/prerender.test.tsvp test run tests/build-report.test.tsvp test run tests/routing.test.ts -t "layout routes whose own content is parallel slot pages|nested parallel slot sub-routes from layout-only parent"vp test run tests/build-report.test.ts tests/prerender.test.ts tests/routing.test.ts -t "layout-only parallel-slot app routes|layout-only routes|layout routes whose own content is parallel slot pages|nested parallel slot sub-routes from layout-only parent"vp check packages/vinext/src/build/prerender.ts packages/vinext/src/build/report.ts tests/prerender.test.ts tests/build-report.test.tsRisks / follow-ups
This intentionally does not change route graph discovery or request-time rendering. It only fixes build-time route collection/classification for routes already discovered and renderable by the App Router runtime.
Closes #1052