Skip to content

fix(prerender): render layout-only parallel slot routes#1091

Merged
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/fix-layout-only-prerender
May 6, 2026
Merged

fix(prerender): render layout-only parallel slot routes#1091
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:nathan/fix-layout-only-prerender

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

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-collision are rendered instead of being silently omitted from vinext-prerender.json and exported output.

Why

The prerender loop used route.pagePath as the only signal that an App Router route had renderable UI. That is too narrow for parallel routes: a segment can have layout.tsx with no page.tsx, while its visible content is supplied by a parallel slot page or default module.

Next.js models this differently:

Approach

Add a shared getAppRouteRenderEntryPath() helper that defines the App Router render-entry contract for build-time consumers:

  • children page.tsx remains the primary render entry
  • route handlers without UI remain API-only
  • layout-only UI routes fall back to the first parallel slot pagePath, then the first slot defaultPath

Both 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.ts
  • vp test run tests/build-report.test.ts
  • vp 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.ts

Risks / 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

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.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 6, 2026 04:38
Copilot AI review requested due to automatic review settings May 6, 2026 04:38
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1091

commit: 7deeed9

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, then defaultPath — 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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review submitted. Here's a summary of what I reviewed:

Approved with two minor non-blocking observations:

  1. First-wins semantics on parallel slot iteration — When multiple parallel slots exist, the first slot's pagePath/defaultPath is used for classification. This is correct for the "is there renderable UI?" question but worth documenting for future readers.

  2. Stale @param docstringclassifyAppRoute's pagePath parameter doc still says "Absolute path to the page.tsx" but now receives slot pages and slot defaults too. Suggested updating in a follow-up.

The core logic is sound — the getAppRouteRenderEntryPath() helper correctly implements the fallback chain (pagePath → slot pagePath → slot defaultPath) matching how the App Router runtime resolves visible content for layout-only segments. Both prerender and build-report now share the same render-entry contract, which is the right design.

github run

@james-elicx james-elicx merged commit f1c27bb into cloudflare:main May 6, 2026
28 of 30 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Layout-only parallel slot routes are skipped by prerender/static export

3 participants