Skip to content

feat(build): wire build-time layout classification into RSC entry#842

Open
NathanDrake2406 wants to merge 7 commits intocloudflare:mainfrom
NathanDrake2406:feat/pr-768-5-build-classification-wire
Open

feat(build): wire build-time layout classification into RSC entry#842
NathanDrake2406 wants to merge 7 commits intocloudflare:mainfrom
NathanDrake2406:feat/pr-768-5-build-classification-wire

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 14, 2026

Summary

Adds a build-time layout classification pipeline. A Rollup generateBundle hook patches the __VINEXT_CLASS stub in the generated RSC entry with a real dispatch table, built from segment-config (Layer 1) and module-graph (Layer 2) analysis. The runtime probe loop in app-page-execution.ts consults this table and skips the Layer 3 dynamic-isolation probe for layouts already proved static or dynamic at build time.

This lands as standalone reusable infrastructure. Build-time classification is the precondition for any future layout-skip optimization, independent of how the skip mechanism is implemented at runtime.

What changes

  • New build/route-classification-manifest.ts bridges the classifier with the entry template codegen.
  • New build/layout-classification-types.ts carries the tagged LayoutBuildClassification and ClassificationReason types. ClassificationReason is consumed by the debug sidecar follow-up; including it here keeps the codegen machinery self-contained.
  • classifyLayoutSegmentConfig in build/report.ts now returns LayoutBuildClassification instead of "static" | "dynamic" | null.
  • The runtime classification block in the generated RSC entry threads buildTimeClassifications through.
  • The generateBundle hook fails loudly if it sees __VINEXT_CLASS referenced without the recognized stub body, so the codegen template and the plugin cannot silently drift.

Context

This was originally PR 5 of a 6-PR stack (#838, #839, #840, #841, this PR, #843). #838 and #839 landed. #840 and #841 are closed: their server-side skip mechanism relied on rewriting the React Flight wire-format payload on egress, which proved too coupled to React-internal tag conventions for a framework that does not vendor React. The skip-mechanism design is being reworked separately around render-time omission rather than byte-level filtering.

This PR is unaffected by that decision. The classifier is needed for any approach. The runtime probe loop already short-circuits on its output, so this PR is useful even before the new skip mechanism lands.

#843 still depends on this PR for the ClassificationReason machinery.

Test plan

  • tests/route-classification-manifest.test.ts (44 tests)
  • tests/build-time-classification-integration.test.ts (5 tests)
  • tests/build-report.test.ts
  • tests/layout-classification.test.ts
  • tests/app-router.test.ts (build-time classification dispatch stub cases)
  • tests/entry-templates.test.ts (snapshot regenerated)

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 14, 2026

Open in StackBlitz

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

commit: 28b317f

@NathanDrake2406 NathanDrake2406 force-pushed the feat/pr-768-5-build-classification-wire branch from dc802fc to 8a427d8 Compare April 15, 2026 03:47
… RSC entry

Introduce a Rollup generateBundle hook that patches the __VINEXT_CLASS
stub in the generated RSC entry with a real dispatch table built from
Layer 1 segment-config analysis and Layer 2 module-graph classification.
The runtime probe loop in app-page-execution.ts consults this table and
skips the dynamic-isolation probe for layouts we proved static or
dynamic at build time.

Add route-classification-manifest.ts as the codegen glue between the
classifier and the entry template, and flow buildTimeClassifications
through renderAppPageLifecycle so the runtime probe can honor the
build-time decision. Fail loudly if generateBundle sees __VINEXT_CLASS
referenced without the recognized stub body, so generator and plugin
cannot silently drift.
@NathanDrake2406 NathanDrake2406 force-pushed the feat/pr-768-5-build-classification-wire branch from 8a427d8 to ac081b1 Compare April 17, 2026 07:48
@NathanDrake2406 NathanDrake2406 changed the title feat(build): wire build-time layout classification into the generated RSC entry [5/6] feat(build): wire build-time layout classification into RSC entry Apr 17, 2026
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 17, 2026 07:51
Copilot AI review requested due to automatic review settings April 17, 2026 07:51
Copy link
Copy Markdown

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.

Pull request overview

Introduces build-time layout classification wiring for the App Router RSC entry so the runtime layout probe loop can skip per-layout dynamic isolation when a layout is already proven static/dynamic during the build.

Changes:

  • Add a __VINEXT_CLASS(routeIdx) stub to the generated RSC entry and thread per-route buildTimeClassifications into the runtime classification block.
  • Implement a build-time classification manifest + codegen helpers to produce a per-route dispatch table (Layer 1 segment-config + Layer 2 module-graph “static” proofs).
  • Extend layout-classification plumbing to carry tagged results and structured reasons, with broad test coverage and updated snapshots.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/route-classification-manifest.test.ts New unit tests for manifest collection + codegen replacement helpers.
tests/layout-classification.test.ts Update tests for module-graph classifier to return structured results (incl. first shim match).
tests/build-time-classification-integration.test.ts New integration test that builds a fixture and asserts the emitted __VINEXT_CLASS is patched/populated.
tests/build-report.test.ts Update segment-config classifier tests for tagged LayoutBuildClassification + reasons.
tests/app-router.test.ts Assert the generated RSC entry contains the build-time dispatch stub and per-route routeIdx wiring.
tests/snapshots/entry-templates.test.ts.snap Snapshot updates for the new stub + route fields + classification threading.
packages/vinext/src/index.ts Collect Layer 1 manifest during RSC entry load; patch __VINEXT_CLASS in generateBundle using module graph analysis.
packages/vinext/src/entries/app-rsc-entry.ts Emit __VINEXT_CLASS stub, add routeIdx + __buildTimeClassifications, and pass buildTimeClassifications into runtime classification.
packages/vinext/src/build/route-classification-manifest.ts New manifest bridge + codegen helpers to build the dispatch replacement (and reasons dispatcher).
packages/vinext/src/build/report.ts Change classifyLayoutSegmentConfig to return tagged classification with structured reason (or { kind: "absent" }).
packages/vinext/src/build/layout-classification.ts Return structured module-graph results and propagate tagged classifications + reasons.
packages/vinext/src/build/layout-classification-types.ts New shared discriminated-union types for build-time classification and reasons.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/vinext/src/index.ts Outdated
Comment on lines +1715 to +1721
const canonicalize = (p: string): string => {
try {
return fs.realpathSync.native(p);
} catch {
return p;
}
};
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

This generateBundle hook introduces a local canonicalize() wrapper around fs.realpathSync.native, but the repo already has a tryRealpathSync() helper (src/build/ssr-manifest.ts) used for path canonicalization. Reusing the shared helper (tryRealpathSync(p) ?? p) would reduce duplication and keep realpath handling consistent across the codebase.

Suggested change
const canonicalize = (p: string): string => {
try {
return fs.realpathSync.native(p);
} catch {
return p;
}
};
const canonicalize = (p: string): string => tryRealpathSync(p) ?? p;

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +74
for (const route of routes) {
const layer1 = new Map<number, Layer1Class>();
const layer1Reasons = new Map<number, ClassificationReason>();

for (let layoutIndex = 0; layoutIndex < route.layouts.length; layoutIndex++) {
const layoutPath = route.layouts[layoutIndex]!;
let source: string;
try {
source = fs.readFileSync(layoutPath, "utf8");
} catch (cause) {
throw new Error(
`vinext: failed to read layout for route ${route.pattern} at ${layoutPath}`,
{ cause },
);
}
const result = classifyLayoutSegmentConfig(source);
if (result.kind === "static" || result.kind === "dynamic") {
layer1.set(layoutIndex, result.kind);
layer1Reasons.set(layoutIndex, result.reason);
}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

collectRouteClassificationManifest reads each layout file once per route; shared layouts (especially the root layout) will be re-read many times in large apps, adding avoidable synchronous I/O during the build. Consider memoizing by layoutPath (e.g., Map<path, source> or Map<path, classification>) so each unique layout file is read/classified only once across all routes.

Copilot uses AI. Check for mistakes.
Comment on lines +1747 to +1770
const layer2PerRoute = new Map<number, Map<number, "static">>();
for (let routeIdx = 0; routeIdx < rscClassificationManifest.routes.length; routeIdx++) {
const route = rscClassificationManifest.routes[routeIdx]!;
const perRoute = new Map<number, "static">();
for (let layoutIdx = 0; layoutIdx < route.layoutPaths.length; layoutIdx++) {
// Skip layouts already decided by Layer 1 — segment config is
// authoritative, so there is no need to walk the module graph.
if (route.layer1.has(layoutIdx)) continue;
const layoutModuleId = canonicalize(route.layoutPaths[layoutIdx]!);
// If the layout module itself is not in the graph, we have no
// evidence either way — do NOT claim it static, or we would skip
// the runtime probe for a layout we never actually analysed.
// `classifyLayoutByModuleGraph` returns "static" for an empty
// traversal, so the seed presence check has to happen here.
if (!moduleInfo.getModuleInfo(layoutModuleId)) continue;
const graphResult = classifyLayoutByModuleGraph(
layoutModuleId,
dynamicShimPaths,
moduleInfo,
);
if (graphResult.result === "static") {
perRoute.set(layoutIdx, "static");
}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

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

In generateBundle, Layer 2 BFS classification is performed per (routeIdx, layoutIdx) even when many routes share the same layout module (commonly the root layout). This can lead to O(routes × shared-layouts) repeated module-graph traversals. Consider caching classifyLayoutByModuleGraph results by canonicalized layoutModuleId and reusing them when populating perRoute maps.

Copilot uses AI. Check for mistakes.
Three targeted cleanups from code review:

- Replace inline canonicalize() with tryRealpathSync(p) ?? p, which is
  already imported and does the same thing (realpathSync.native + fallback)

- Memoize readFileSync calls in collectRouteClassificationManifest so
  shared layouts (especially the root layout) are read from disk only once
  per build rather than once per route

- Cache classifyLayoutByModuleGraph results by canonicalized module ID in
  generateBundle so shared layouts are BFS-traversed only once per build
  rather than once per (routeIdx, layoutIdx) pair
@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.

Review: Build-time layout classification wiring

This is well-structured infrastructure work. The layered classification design (segment-config → module-graph → runtime probe) is clean, the priority semantics are correct, and the generateBundle hook's defensive error handling is a real strength. The test coverage is thorough — both the unit tests and the full-pipeline integration test.

A few issues worth addressing:

Key concerns

  1. buildReasonsReplacement is dead code in this PR. It's exported, tested, and documented, but never called from index.ts. The generateBundle hook only calls buildGenerateBundleReplacement. The PR description says ClassificationReason is consumed by a debug sidecar follow-up (#843), which is fine, but the function itself should either be called behind the VINEXT_DEBUG_CLASSIFICATION env check in this PR or deferred entirely to #843. Shipping tested-but-uncalled code creates maintenance risk — someone will see the tests passing and assume it's wired up.

  2. Minor: unused destructured _value in merge function. The for (const [layoutIdx, _value] of layer2) destructuring is an unnecessary allocation. Since layer2 is typed as ReadonlyMap<number, "static">, the value is always "static" and never read — but the underscore-prefixed binding still participates in iteration. for (const layoutIdx of layer2.keys()) would be clearer.

  3. The __VINEXT_CLASS stub regex extraction in integration tests is fragile. The regex /(\([\s\S]*?\))\(routeIdx\)/ uses a non-greedy match for the parenthesized function body. If the generated switch statement itself contains )(routeIdx) as a substring (unlikely today, but possible if the codegen changes), this regex would break. Consider anchoring the regex more specifically or adding a comment noting this coupling.

Architecture observations

  • The Copilot reviewer's point about memoizing classifyLayoutByModuleGraph results is already addressed in this PR via graphCache in the generateBundle hook (line 1742 of index.ts). That comment appears to have missed the cache. The sourceCache in collectRouteClassificationManifest similarly deduplicates Layer 1 file reads. Both caches are correctly scoped.

  • The canonicalize helper reuses tryRealpathSync from ssr-manifest.ts (already imported in index.ts), which is the right call. The Copilot suggestion to refactor was misleading — it's already using the shared helper.

  • The stub-body drift detection (throwing when __VINEXT_CLASS is referenced but the stub body isn't found) is a good pattern. It catches a real failure mode: upstream codegen changes that reshape the function without updating the generateBundle regex.

  • The runtime consumer in app-page-execution.ts correctly short-circuits on build-time results and still probes for special errors (redirects, not-found). This means build-time classification skips only the dynamic isolation overhead, not error detection. That's the right trade-off.

Nits

  • The snapshot diff is ~1000 lines of mechanical repetition (same stub block × 7 fixtures). Not actionable here, but snapshot-heavy tests for generated code are a maintenance burden worth considering alternatives for eventually.

const merged = new Map<number, MergedLayoutClassification>();

if (layer2) {
for (const [layoutIdx, _value] of layer2) {
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.

The _value is never read and always "static" by type. Using layer2.keys() is cleaner:

Suggested change
for (const [layoutIdx, _value] of layer2) {
for (const layoutIdx of layer2.keys()) {

" }",
" }",
].join("\n");
}
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.

buildReasonsReplacement is exported and fully tested but never called from the plugin. The generateBundle hook in index.ts only calls buildGenerateBundleReplacement. The docstring says it's gated behind VINEXT_DEBUG_CLASSIFICATION, but this PR doesn't implement that gate.

This is dead code that creates a false sense of completeness. Either:

  1. Wire it up behind the env check in this PR (even if the debug sidecar UI comes in chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6] #843), or
  2. Move it entirely to chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6] #843 so the exported surface matches what's actually used.

Leaving it tested-but-uncalled means a future refactor of the merge logic could break the reasons codepath without anyone noticing in production — the tests would still pass but the feature would be silently absent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

next PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional. buildReasonsReplacement ships here because #843 imports it from this module — the reasons-dispatch codegen is infrastructure shared between the two PRs. Keeping the function and its tests here means #843 does not need to re-add a dependency on untested code. The wiring (stub injection + generateBundle patch behind VINEXT_DEBUG_CLASSIFICATION) lives in #843 where the full debug surface is assembled.

if (this.environment?.name !== "rsc") return;
if (!rscClassificationManifest) return;

const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/;
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.

The return null;? optional semicolon in the regex is a good defensive choice for Rolldown's output variance, but the stub body emitted from app-rsc-entry.ts (line 753) always includes the semicolon. Consider adding a brief comment noting this is intentional tolerance for bundler output variation, so future readers don't think it's accidental.

);
return ` {
routeIdx: ${routeIdx},
__buildTimeClassifications: __VINEXT_CLASS(${routeIdx}),
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.

The __buildTimeClassifications property is eagerly evaluated at module load time (not at request time) because __VINEXT_CLASS(routeIdx) is called during the routes = [...] array construction. This is correct and intentional — the dispatch table is static data. But the double-underscore prefix + the fact that it's a call expression makes it look like it might be deferred.

Minor: consider a brief inline comment like // evaluated once at module load so readers don't have to trace to the stub definition to understand the evaluation semantics.

}

const re =
/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/;
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.

This non-greedy [\s\S]*? will match the shortest string between ( and ). If the inner switch statement contains a closing ) followed by (routeIdx) (e.g., from a future codegen change that wraps an IIFE inside the body), this regex would capture too little.

Not a blocking issue since the codegen is controlled, but worth a comment noting the coupling:

Suggested change
/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/;
/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/;
// Note: non-greedy match assumes the inner dispatch function body does not
// contain ')(routeIdx)' as a substring. Coupled to the codegen in
// route-classification-manifest.ts.

// the runtime probe for a layout we never actually analysed.
// `classifyLayoutByModuleGraph` returns "static" for an empty
// traversal, so the seed presence check has to happen here.
if (!moduleInfo.getModuleInfo(layoutModuleId)) continue;
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.

Good catch on the seed presence check. classifyLayoutByModuleGraph returns { result: "static" } for an empty BFS (no module info), which would falsely mark unknown layouts as static. This guard is essential. The comment explains it well.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 17, 2026

�[33m[STARTED]�[39m Backing up original state...
�[32m[COMPLETED]�[39m Backed up original state in git stash (faf71f8)
�[33m[STARTED]�[39m Running tasks for staged files...
�[33m[STARTED]�[39m Config object�[0;90m — 1 file�[0m
�[33m[STARTED]�[39m *�[0;90m — 1 file�[0m
�[33m[STARTED]�[39m vp check --fix
�[31m[FAILED]�[39m �[0;31mvp check --fix�[0m �[0;90m[FAILED]�[0m
�[31m[FAILED]�[39m �[0;31mvp check --fix�[0m �[0;90m[FAILED]�[0m
�[32m[COMPLETED]�[39m Running tasks for staged files...
�[33m[STARTED]�[39m Applying modifications from tasks...
�[33m[SKIPPED]�[39m Skipped because of errors from tasks.
�[33m[STARTED]�[39m Reverting to original state because of errors...
�[32m[COMPLETED]�[39m Reverting to original state because of errors...
�[33m[STARTED]�[39m Cleaning up temporary files...
�[32m[COMPLETED]�[39m Cleaning up temporary files...

�[0;31m�[0;31m✖�[0m vp check --fix:�[0m
�[1m�[31merror:�[39m�[0m Linting could not start
No files found to lint. Please check your paths and ignore patterns.
Finished in 164ms on 0 files with 127 rules using 4 threads.

Linting failed before analysis started
VITE+ - pre-commit script failed (code 1)

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 17, 2026

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

- Use layer2.keys() instead of destructuring with unused _value
- Add comment to stub regex explaining intentional semicolon tolerance
- Add comment to __buildTimeClassifications clarifying module-load evaluation
- Add coupling comment to integration test regex explaining non-greedy assumption
@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.

Review

This is solid infrastructure work. The layered classification pipeline (segment-config → module-graph → runtime probe) is well-factored, the type design is clean, and the defensive generateBundle error handling for stub drift is a real strength. The integration test that exercises the full Vite build pipeline is especially good — it catches the class of silent regression (stub never patched → Layer 3 fallback) that originally motivated the PR.

A few items worth addressing:

1. rscClassificationManifest is never reset between builds

The manifest is captured during the load hook and consumed in generateBundle, but it's never cleared afterward. In a long-lived dev server that rebuilds the RSC entry (e.g., on HMR when a new route is added), the manifest from a previous build could leak into a subsequent one if the load hook isn't re-triggered for the RSC entry. In practice this likely doesn't matter today because the stub is only patched in production builds, but it's a latent footgun if generateBundle ever runs in dev-like contexts.

2. Stub regex doesn't account for bundler renaming

The stubRe pattern matches __VINEXT_CLASS as a literal function name. If Rolldown ever applies scope-hoisting or function renaming in the RSC environment (even without full minification), the regex won't match and the hook will throw the drift error. This is fine as long as the RSC entry is always the entry chunk (entry chunk bindings aren't renamed), but it's worth a comment noting that assumption explicitly.

3. Layer 2 "static" type in layer2PerRoute is slightly misleading

The map type is Map<number, Map<number, "static">>, but the value is always the literal "static" — it's effectively a Set. Using Map<number, Set<number>> and checking membership would be semantically clearer and avoid the question of what happens if someone adds "dynamic" as a Layer 2 value.

Minor

  • The serializeReasonExpression function handles the "runtime-probe" and "no-classifier" variants of ClassificationReason, but these are never produced by the build-time pipeline — they come from the runtime. No bug, since the function is correct for all variants, but it means the codegen is carrying dead branches for the debug sidecar that don't have any build-time producer yet.

  • The integration test afterAll is intentionally a no-op (leaves tmpdirs for debugging). This is fine for CI but worth noting if anyone runs the suite locally in a loop — it'll accumulate temp directories.

Overall this is well-structured and ready to land after the comments below are considered. The caching strategy (sourceCache for Layer 1, graphCache for Layer 2) addresses the performance concerns from the earlier review.


// The `?` after the semicolon is intentional: Rolldown may or may not
// emit the trailing semicolon depending on minification settings.
const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/;
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.

The ? after the semicolon is a good idea for Rolldown output variance, but the regex also assumes __VINEXT_CLASS is never renamed by the bundler. Since this is an entry chunk binding, Rolldown won't rename it under normal settings. Worth a brief comment noting that assumption so future readers know it's intentional:

Suggested change
const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/;
// The `?` after the semicolon is intentional: Rolldown may or may not
// emit the trailing semicolon depending on minification settings.
// This regex relies on `__VINEXT_CLASS` retaining its name, which holds
// because RSC entry chunk bindings are not subject to scope-hoisting renames.
const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/;

// Collect Layer 1 (segment config) classifications for all layouts.
// Layer 2 (module graph) runs later in generateBundle once Rollup's
// module info is available.
rscClassificationManifest = collectRouteClassificationManifest(routes);
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.

The manifest is captured here and consumed in generateBundle, but never cleared. If a rebuild of the RSC entry occurs without re-triggering this load hook (e.g., a hot-reload path that reuses the previous module), generateBundle would consume a stale manifest.

In practice this is likely safe because generateBundle only runs in production builds and the stub check gates everything. But a comment or a defensive rscClassificationManifest = null at the top of generateBundle (after the early returns) would make the lifecycle contract explicit.

}
}

for (const [layoutIdx, value] of route.layer1) {
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.

The route.layer1 iteration destructures [layoutIdx, value] but value carries the kind ("static" | "dynamic") which is already available. This is fine. However, the invariant check on layer1Reasons (lines 124-127) protects against a data integrity issue where layer1 and layer1Reasons are out of sync — but this can only happen if collectRouteClassificationManifest has a bug since both maps are populated in lockstep on lines 75-76. The throw is good defense-in-depth, but consider whether a debug.assert would be more appropriate here to avoid the overhead in production builds.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The throw is the right choice here. node:assert is the only assert facility available in this codebase and it throws at the same cost — there is no stripped debug-only assert utility. The intent is fail-fast on a programmer error (layer1 and layer1Reasons out of sync), which throw expresses correctly. Left as-is.

},
};

const layer2PerRoute = new Map<number, Map<number, "static">>();
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.

Minor: Map<number, Map<number, "static">> is effectively a Map<number, Set<number>> since the value is always the literal "static". Using a Set would make the intent clearer and avoid the question of what other values could appear. That said, this matches the type signature of buildGenerateBundleReplacement's parameter, so changing it would ripple — not blocking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on the semantics, but changing this would require updating the parameter type of buildGenerateBundleReplacement (and its sibling buildReasonsReplacement in #843), plus the layer2PerRoute maps built in the generateBundle hook. Deferring as a follow-up since the ripple isn't worth it mid-stack.

*/
export function buildGenerateBundleReplacement(
manifest: RouteClassificationManifest,
layer2PerRoute: ReadonlyMap<number, ReadonlyMap<number, "static">>,
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.

The layer2PerRoute parameter is typed as ReadonlyMap<number, ReadonlyMap<number, "static">>. This is the right constraint — Layer 2 can only prove static, never dynamic. But the docstring on line 167 says "Layer 2 results must be filtered to only "static" before calling this function" — this implies the caller could pass unfiltered results. The type already enforces this at compile time, so the docstring is slightly misleading. Consider rewording to "Layer 2 results are typed to only carry "static" entries" to reflect that this is a type-level guarantee, not a caller responsibility.

const builder = await createBuilder({
root: tmpDir,
configFile: false,
plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })],
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.

The vinext() call passes { appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir } — are appDir, rscOutDir, ssrOutDir, clientOutDir all real vinext options? I see appDir referenced in the fixture but the main plugin options type may not include all of these. If these are undocumented/internal options, that's fine for a test, but worth a brief comment noting they're internal-only build knobs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are all public VinextOptions fields (appDir, rscOutDir, ssrOutDir, clientOutDir are all declared in the exported VinextOptions type). No special comment needed.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 17, 2026

The review has been submitted. Here's a summary of what I covered:

Key findings:

  1. Manifest lifecyclerscClassificationManifest is captured in load but never cleared after generateBundle consumes it. Latent issue if the hook ever runs outside production builds.

  2. Stub regex bundler assumption — The regex matching __VINEXT_CLASS by literal name depends on Rolldown not renaming entry chunk bindings. Suggested adding a comment documenting this assumption.

  3. Map<number, "static"> vs Set<number> — The Layer 2 per-route map always holds the literal "static", making it effectively a Set. Noted as non-blocking since it matches the downstream API signature.

  4. Inline comments on: the stub regex tolerance comment, the stale manifest concern, the defense-in-depth invariant check in mergeLayersForRoute, the docstring for layer2PerRoute type constraint, and the integration test's internal-only plugin options.

Positive observations:

  • The sourceCache and graphCache already address the memoization concerns from the Copilot review
  • The canonicalize helper correctly reuses tryRealpathSync (contrary to the Copilot suggestion)
  • The stub-body drift detection is a good pattern
  • The runtime consumer correctly short-circuits on build-time results while still probing for special errors
  • Integration test coverage is thorough

github run

- Expand stubRe comment to note __VINEXT_CLASS name-retention assumption
- Null out rscClassificationManifest after generateBundle consumes it
- Fix buildGenerateBundleReplacement docstring to reflect type-level guarantee
@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.

Review

This is well-structured infrastructure work with a clean layered design. The type system (discriminated unions for classification + reasons), the defensive generateBundle error handling, and the caching strategy (sourceCache for Layer 1, graphCache for Layer 2) are all solid. The integration test that exercises the full Vite build pipeline is the right investment — it catches the exact class of silent regression this PR exists to prevent.

A few items below, roughly ordered by severity.

Substantive

  1. Source map invalidation on target.code mutation. The generateBundle hook directly mutates target.code without touching target.map. If Rolldown emitted a source map for the RSC entry chunk, the replacement text will be longer than the stub it replaced (a switch statement vs return null), and every line after the patch point will have incorrect source mappings. This may not matter today if RSC entry source maps are not consumed, but it's a latent correctness issue. Consider either invalidating the map (target.map = null) or noting explicitly why it's safe to skip.

  2. The routeIdx property is unnecessary runtime data. Every route object in the generated routes = [...] array now carries a routeIdx field, but it's never read at runtime — it exists solely to generate the __VINEXT_CLASS(N) call expression during codegen. Since the call is evaluated at module load time and the result is stored in __buildTimeClassifications, routeIdx is dead weight in the runtime route table. Consider removing it from the route object and only using the loop variable routeIdx during codegen. This keeps the generated route table lean.

  3. __VINEXT_CLASS is untyped in the generated code. The stub function has no parameter type annotation and no return type. While this is inside a generated entry (not hand-authored TypeScript), the routeIdx parameter being untyped means Rolldown/Vite sees it as any. Not a runtime issue since the function is always called with a numeric literal, but adding a brief @param {number} routeIdx JSDoc or a TS annotation would make the generated code more self-documenting for anyone debugging the built output.

);
const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`;
const target = chunksWithStubBody[0]!.chunk;
target.code = target.code.replace(stubRe, patchedBody);
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.

This mutates target.code without updating target.map. If the RSC entry chunk has an associated source map, the map will become stale after this replacement (the patched body is longer than the stub). Consider either:

  1. Invalidating the map: target.map = null; (safe if source maps aren't consumed for RSC entries)
  2. Documenting why map correctness doesn't matter here

Not blocking if RSC entry source maps are never served, but worth a defensive line.

ep ? getImportVar(ep) : "null",
);
return ` {
routeIdx: ${routeIdx},
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.

routeIdx is emitted as a runtime property on every route object but is never read after module load — the __VINEXT_CLASS(${routeIdx}) call is already evaluated eagerly and stored in __buildTimeClassifications. This property is dead weight in the runtime route table.

Consider removing it from the generated route object. The codegen already has routeIdx available as the routes.map callback index — it only needs to appear in the __VINEXT_CLASS(N) call expression, not as a stored property.

Suggested change
routeIdx: ${routeIdx},
__buildTimeClassifications: __VINEXT_CLASS(${routeIdx}), // evaluated once at module load

Comment on lines +753 to +755
function __VINEXT_CLASS(routeIdx) {
return null;
}
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.

The generated stub is plain JavaScript with no type annotations. Since this is inside a .ts-templated entry that goes through the RSC build, consider adding a minimal return type to make the generated output more self-documenting:

Suggested change
function __VINEXT_CLASS(routeIdx) {
return null;
}
function __VINEXT_CLASS(routeIdx) {
return /** @type {Map<number, 'static' | 'dynamic'> | null} */ (null);
}

Non-blocking — this is cosmetic for anyone debugging the built RSC chunk.

function serializeReasonExpression(reason: ClassificationReason): string {
switch (reason.layer) {
case "segment-config": {
const value = reason.value === Infinity ? "Infinity" : JSON.stringify(reason.value);
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.

Minor: when reason.value is a number (e.g., 0), JSON.stringify(0) produces "0" which is correct. But the === Infinity check is order-dependent — it must come before JSON.stringify because JSON.stringify(Infinity) returns "null", which would silently produce value: null in the generated code. The current ordering is correct, but this is subtle enough to warrant a brief comment.

Suggested change
const value = reason.value === Infinity ? "Infinity" : JSON.stringify(reason.value);
// Infinity must be checked first: JSON.stringify(Infinity) produces "null".
const value = reason.value === Infinity ? "Infinity" : JSON.stringify(reason.value);

Comment on lines +1721 to +1727
const dynamicShimPaths: ReadonlySet<string> = new Set(
[
resolveShimModulePath(shimsDir, "headers"),
resolveShimModulePath(shimsDir, "server"),
resolveShimModulePath(shimsDir, "cache"),
].map(canonicalize),
);
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.

The dynamic shim set is constructed on every generateBundle invocation. Since shimsDir is stable for the lifetime of the plugin, this could be hoisted to plugin initialization (alongside the existing shimsDir definition). Minor perf nit — not blocking since generateBundle runs once per build.

* it via the plugin's generateBundle hook.
*/
function extractDispatch(chunkSource: string): Dispatch {
const stubRe = /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return null;\s*\}/;
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.

The stubRe in the test differs subtly from the one in the plugin: the test regex requires the semicolon (return null;\s*\}), while the plugin regex makes it optional (return null;?\s*\}). If Rolldown ever strips the semicolon, the plugin would handle it correctly but this test's "was it patched?" check would false-negative (it would not detect the unpatched stub, so it wouldn't throw, and the subsequent regex extraction would fail with a confusing error).

Consider making the test regex match the plugin's tolerance:

Suggested change
const stubRe = /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return null;\s*\}/;
const stubRe = /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/;

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 17, 2026

The review has been submitted. Here's what I covered:

Substantive findings (3):

  1. Source map invalidationtarget.code is mutated in generateBundle without touching target.map. If Rolldown emits source maps for the RSC entry, everything after the patch point will have incorrect mappings. Suggested either target.map = null or a comment explaining why it's safe.

  2. Dead routeIdx property — Every route object in the generated array carries a routeIdx field that is never read at runtime. The __VINEXT_CLASS(N) call is evaluated at module load and stored in __buildTimeClassifications, making routeIdx unnecessary runtime data. Suggested removing it from the route object.

  3. Untyped __VINEXT_CLASS stub — The generated function has no type annotations. Non-blocking but suggested a JSDoc annotation for debuggability.

Inline comments (6):

  • Source map invalidation on target.code mutation (index.ts:1785)
  • Remove dead routeIdx property from route table (app-rsc-entry.ts:217)
  • Add return type to the stub function (app-rsc-entry.ts:753-755)
  • Document the JSON.stringify(Infinity) pitfall in serializeReasonExpression (route-classification-manifest.ts:138)
  • Dynamic shim set could be hoisted to plugin init (index.ts:1721-1727)
  • Integration test stubRe should match plugin's semicolon tolerance (build-time-classification-integration.test.ts:41)

Positive observations: The layered classification design is clean, caching is correctly scoped, the stub-body drift detection is a good defensive pattern, and the runtime consumer correctly short-circuits on build-time results while still probing for special errors.

github run

- Remove routeIdx from generated route objects (dead runtime property,
  only needed as codegen loop variable for __VINEXT_CLASS(N) call)
- Null target.map after patching target.code in generateBundle
- Add Infinity-before-JSON.stringify comment in serializeReasonExpression
- Fix integration test stubRe to tolerate optional semicolon like the plugin
- Update extractRouteIndexByPattern to anchor on __VINEXT_CLASS(N) instead
  of the removed routeIdx property
- Update snapshots
- Mark classifyAllRouteLayouts as @internal test-only (production code
  calls classifyLayoutByModuleGraph directly via the manifest pipeline)
- Document unreachable runtime-probe/no-classifier arms in
  serializeReasonExpression with explanation of why narrowing is deferred
- Add manifest lifecycle invariant comment to load hook explaining the
  1:1 correspondence between manifest layout indices and codegen routes
- Add test for layer1/layer1Reasons mismatch guard in mergeLayersForRoute
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.

3 participants