Skip to content

fix(app-router): reject interception routes in static export#2365

Open
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-interception-static-export
Open

fix(app-router): reject interception routes in static export#2365
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-interception-static-export

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • reject App Router static exports when the route graph contains sibling or parallel-slot interception routes
  • match Next.js's build-time error instead of attempting to prerender an unsupported route topology
  • add a focused scanner/export regression covering the exact Next.js fixture shape

Next.js parity

Fixes the failure from run 28143992598 in:

  • test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts

Reference: https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts

Validation

  • vp test run tests/app-router-static-export.test.ts -t 'rejects interception routes' --maxWorkers=1
  • vp check packages/vinext/src/build/static-export.ts tests/app-router-static-export.test.ts
  • git diff --check

Full suites are deferred to CI.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2365
npm i https://pkg.pr.new/vinext@2365

commit: 7bdbdcb

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk ask-bonk Bot left a comment

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.

Review: reject interception routes in static export

I verified the type usage, the error-message parity with Next.js, and the new test (which passes). The implementation is clean and the message matches Next.js (v16.2.6) exactly. However, there's one blocking correctness issue: the guard is added to a function the real export build never calls, so it does not actually fix the Next.js parity failure this PR targets.

Blocking: guard is in a dead code path for output: 'export' builds

staticExportApp() is only invoked by tests and re-exported as a public API from index.ts. The actual vinext build flow for output: 'export' is:

  • cli.ts:626shouldPrerender = ... || resolvedNextConfig.output === "export"
  • cli.ts:641await runPrerender(...)
  • run-prerender.ts:210const mode = config.output === "export" ? "export" : "default"
  • run-prerender.ts:269await prerenderApp({ mode, routes, ... }) ← called directly, bypassing staticExportApp()

So during a genuine static export build, hasInterceptionRoutes() is never reached, and a project with @modal/(.)page (the exact Next.js fixture shape) will attempt to prerender instead of failing with the build-time error. The Next.js test asserts nextBuild exits with code === 1 and stderr contains the message; the equivalent vinext build path would not produce that error.

The misleading comment at run-prerender.ts:229-230 reinforces this — it claims export builds are "handled by static-export.ts", but the CLI clearly routes export builds through runPrerenderprerenderApp.

Suggested fix: move (or duplicate) the interception check so it runs on the real export path. The cleanest spot is in run-prerender.ts right after const routes = await appRouter(...) (line 262), guarded by mode === "export", throwing the same message. Keeping the existing check in staticExportApp is fine for the public-API surface, but the export build must enforce it too. Consider extracting hasInterceptionRoutes to a shared module so both call sites use one implementation.

Test coverage gap (related to the above)

The new test calls staticExportApp() directly, so it passes even though the real build path is unguarded. To actually protect against regressions, add an assertion against the path the CLI uses — e.g. drive runPrerender (or prerenderApp with mode: "export") over the same fixture and assert it rejects. As written, the test gives false confidence that vinext build rejects interception routes.

Non-blocking notes

  • Parity is otherwise good: error string matches Next.js v16.2.6 verbatim, and the fixture mirrors test/e2e/app-dir/interception-routes-output-export (the omitted top-level app/default.tsx doesn't affect interception detection).
  • vp check passes on both changed files; the type access of siblingIntercepts / parallelSlots[].interceptingRoutes is sound (AppRouteGraphRoute is a superset of AppRoute).

Once the guard is enforced on the runPrerender path with a test that exercises it, this is good to merge.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2365.

Summary

I reviewed PR #2365 (reject interception routes in static export) and found one blocking issue:

The guard is in a dead code path. The PR adds hasInterceptionRoutes() to staticExportApp(), but the actual vinext build flow for output: 'export' never calls that function:

  • cli.ts:626,641runPrerender() when output === "export"
  • run-prerender.ts:210 sets mode = "export", then :269 calls prerenderApp() directly

staticExportApp() is only invoked by tests and re-exported as a public API. So a real static-export build of the exact Next.js fixture shape (@modal/(.)page) would still attempt to prerender instead of failing with the build error — meaning the PR doesn't actually fix the parity failure it targets. A misleading comment at run-prerender.ts:229-230 reinforced the wrong assumption.

The test passes but tests the wrong path — it calls staticExportApp() directly, giving false confidence that vinext build rejects interception routes.

What's correct:

  • Error message matches Next.js v16.2.6 verbatim
  • Fixture mirrors the Next.js reference fixture
  • Type access is sound; vp check passes on both files

Recommended fix: move/duplicate the check into run-prerender.ts (guarded by mode === "export"), extract hasInterceptionRoutes to a shared module, and add a test that drives runPrerender/prerenderApp over the fixture.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 7bdbdcb against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 117.1 KB ⚫ -0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ -0.0%
Dev server cold start vinext 2.59 s 2.55 s ⚫ -1.5%
Production build time vinext 3.12 s 3.10 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ -0.0%
Server bundle size (gzip) vinext 156.9 KB 156.9 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: reject interception routes in static export — blocking issue resolved ✅

This re-review follows up on my previous one, which flagged that the guard was added to a dead code path (staticExportApp(), which the real vinext build --output export flow never calls). The new commits (9ec5bd0, c03a71f, 1a4adf7) fix that correctly.

What's now correct

1. Guard is enforced on the real export path. assertNoStaticExportInterceptionRoutes() was extracted to a shared module (build/interception-static-export.ts) and is now called in run-prerender.ts:204-206, gated by mode === "export", before the prod server starts and before prerenderApp() runs. appRouter() is hoisted to line 203 and reused (no duplicate scan). This is exactly the path cli.ts drives for output: 'export', so the parity failure from run 28143992598 is genuinely fixed now.

2. Process-state leak avoided — and deliberately so. The VINEXT_PRERENDER env set/clear was moved from line ~169 to after the interception check (now line 242). This guarantees the validation throw happens before any global flag is set, so a rejected export build can't leak VINEXT_PRERENDER=1. The comment at 238-241 documents the ordering intent. Good defensive sequencing.

3. Test now exercises the real path. tests/prerender.test.ts adds a test that drives runPrerender({ output: 'export' }) over the exact Next.js fixture shape (@modal/(.)page), asserts it rejects with the verbatim message, AND asserts process.env.VINEXT_PRERENDER is unchanged after the throw. This closes the false-confidence gap from the prior review.

Parity verification

  • Error string matches Next.js v16.2.6 verbatim: Intercepting routes are not supported with static export. (confirmed against test/e2e/app-dir/interception-routes-output-export/interception-routes-output-export.test.ts, which asserts stderr.toContain(...) + code === 1).
  • Throwing in runPrerender propagates to a non-zero CLI exit, matching Next.js's build-time error semantics.
  • Fixture mirrors the Next.js reference fixture shape.

Validation run locally

  • vp test run tests/prerender.test.ts -t "rejects App Router interception routes"pass
  • vp test run tests/app-router-static-export.test.ts -t "interception"pass
  • vp check on all three changed source files → pass (format, lint, types clean). siblingIntercepts / parallelSlots[].interceptingRoutes access is sound on AppRoute.

Non-blocking nits

  • The shared helper still keeps the redundant call in staticExportApp() — that's fine (defensive for the public API surface), and both call sites use the one extracted implementation, so there's no divergence risk.
  • Minor: the interception-static-export.ts helper has no JSDoc linking back to the Next.js fixture/test. A one-line // Ported from Next.js: ... reference (per AGENTS.md convention) would help future maintainers, but not required.

LGTM. The blocking issue is resolved and the fix is on the correct path with a test that exercises it.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2365.

Summary

The previous review's blocking issue has been resolved. The new commits (9ec5bd0, c03a71f, 1a4adf7) moved the guard from the dead code path (staticExportApp()) onto the real export build path:

What's now correct:

  • assertNoStaticExportInterceptionRoutes() is extracted to a shared module and called in run-prerender.ts:204-206, gated by mode === "export", before the prod server starts — exactly the path cli.ts drives for output: 'export'.
  • The VINEXT_PRERENDER env flag set/clear was deliberately moved after the interception check so a rejected build can't leak global process state.
  • A new test in prerender.test.ts drives the real runPrerender({ output: 'export' }) over the @modal/(.)page fixture, asserting both the rejection message and that VINEXT_PRERENDER is unchanged.

Parity verified:

  • Error message matches Next.js v16.2.6 verbatim (confirmed against the upstream fixture test, which asserts stderr.toContain(...) + code === 1).

Validation (run locally):

  • tests/prerender.test.ts -t "rejects App Router interception routes" → pass
  • tests/app-router-static-export.test.ts -t "interception" → pass
  • vp check on all 3 changed source files → clean (format, lint, types)

Only non-blocking nits remain (a redundant-but-harmless check in staticExportApp(), and a missing Next.js source reference comment). LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: reject interception routes in static export — verified, no new issues

I independently re-verified this PR after the earlier blocking issue (guard added to the dead staticExportApp() path) was fixed. The current state is correct and on the right code path. Ran the targeted tests, vp check, and git diff --check locally — all green.

What I verified

Guard is enforced on the real export build path. cli.ts routes output: 'export' builds through runPrerenderprerenderApp, never through staticExportApp(). The guard now lives at run-prerender.ts:204-206, gated by mode === "export", before the prod server starts and before prerenderApp(). This is the path the parity failure (run 28143992598) actually exercises.

No duplicate scan / perf regression from the hoist. appRouter() is hoisted to run-prerender.ts:203 and reused at line 264 (routes: appRoutes). appRouteGraph() caches the graph by appDir/pageExtensions (app-router.ts:64-66), so the App Router phase re-read hits the cache. For pages-only builds appDir is null → appRoutes is null → no behavior change.

Process-state ordering is correct. previousPrerenderFlag capture + VINEXT_PRERENDER = "1" was moved to line 242, after the interception throw at line 205. The finally (line 335-342) is paired with the right try, so a rejected export build can't leak VINEXT_PRERENDER. The prerender.test.ts test asserts this explicitly.

Type access is sound. appRouter() returns AppRouteGraphRoute[], a superset of AppRoute. siblingIntercepts and parallelSlots are required (non-optional) fields on AppRoute (app-route-graph.ts:128,134). The helper signature readonly AppRoute[] accepts it.

Parity. Error string Intercepting routes are not supported with static export. matches Next.js v16.2.6 verbatim. Throwing in runPrerender propagates to a non-zero CLI exit, matching Next.js's build-time code === 1 + stderr assertion. Both the helper and the new app-router-static-export.test.ts test carry the // Ported from Next.js: ... reference (the earlier-review nit is addressed).

Test coverage. Both interception shapes are covered: parallel-slot (@modal/(.)page) in prerender.test.ts + app-router-static-export.test.ts, and sibling (source/(.)target) in app-router-static-export.test.ts, with proper invalidateAppRouteCache() between cases.

Note (non-blocking)

The existing HTTP static-export test (static-export.test.ts:237) and the App Router export test (app-router-static-export.test.ts:24) now filter interception routes out of the shared app-basic fixture before calling staticExportApp(). This is necessary because that fixture contains ~20 interception/parallel-slot routes that the new guard would (correctly) reject. The filter is a reasonable workaround to keep those broad-coverage tests focused on non-interception export behavior — just flagging that the shared fixture's interception coverage for export is now exclusively in the dedicated new tests, which is fine.

Validation (local)

  • vitest run tests/prerender.test.ts -t "rejects App Router interception routes" → pass
  • vitest run tests/app-router-static-export.test.ts -t "interception" → pass
  • vitest run tests/static-export.test.ts → 16 pass
  • vp check on all 3 changed source files → clean (format, lint, types)
  • git diff --check → no whitespace errors

LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2365.

Summary

I independently re-verified PR #2365. The earlier blocking issue (guard in the dead staticExportApp() path) was already resolved in commits 9ec5bd0, c03a71f, 1a4adf7. The current state is correct — no new issues found.

Verified:

  • Guard on the real path: assertNoStaticExportInterceptionRoutes() runs at run-prerender.ts:204-206, gated by mode === "export", which is the path cli.ts actually drives for output: 'export' builds.
  • No perf regression: appRouter() is hoisted but the graph is cached by appDir, so the App Router phase re-read hits cache; pages-only builds are unaffected.
  • No process-state leak: the VINEXT_PRERENDER set/clear was deliberately moved after the interception throw; finally restoration is correctly paired.
  • Type soundness: AppRouteGraphRoute is a superset of AppRoute; siblingIntercepts/parallelSlots are required fields.
  • Parity: error string matches Next.js v16.2.6 verbatim; throw → non-zero exit matches the upstream code === 1 + stderr test. The // Ported from Next.js reference nit from the prior review is addressed.
  • Coverage: both parallel-slot and sibling interception shapes are tested across prerender.test.ts and app-router-static-export.test.ts.

One non-blocking note: the two broad existing tests now filter interception routes out of the shared app-basic fixture (which contains ~20 such routes), so interception-export coverage now lives exclusively in the dedicated new tests — reasonable, just flagged.

All targeted tests pass, vp check is clean, and git diff --check shows no issues. LGTM.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 19:21
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.

1 participant