Skip to content

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

Merged
james-elicx merged 10 commits intocloudflare:mainfrom
NathanDrake2406:feat/pr-768-5-build-classification-wire
Apr 20, 2026
Merged

feat(build): wire build-time layout classification into RSC entry#842
james-elicx merged 10 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: 22a1731

@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 thread packages/vinext/src/index.ts Outdated
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.

Comment thread packages/vinext/src/index.ts Outdated
},
};

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 thread packages/vinext/src/index.ts Outdated
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
@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-designed infrastructure. The layered classification pipeline (segment-config → module-graph → runtime probe), the defensive generateBundle stub-drift detection, and the caching strategy (sourceCache for Layer 1, graphCache for Layer 2) are all solid. The integration test exercising the full Vite build pipeline is the right investment — it catches the exact class of silent regression (stub never patched → Layer 3 fallback) that this PR exists to prevent.

The previous reviews covered most surface-level concerns and the author has addressed them (manifest clearing, source map nulling, stub regex comments). I have one remaining issue and a few observations.

Issue: classifyAllRouteLayouts is now dead production code

The @internal annotation on classifyAllRouteLayouts (line 119 of layout-classification.ts) says it's "Not called by production code" and is "Used only by tests/layout-classification.test.ts." This is accurate — the generateBundle hook in index.ts calls classifyLayoutByModuleGraph directly and composes via the numeric-index manifest. But classifyAllRouteLayouts is still exported from the module's public surface.

This isn't blocking, but the function now serves only as test infrastructure for verifying that the Layer 1 → Layer 2 composition logic works. If the generateBundle hook's composition logic ever diverges from what classifyAllRouteLayouts does (they share classifyLayoutByModuleGraph but have different merge strategies — string-keyed layout IDs vs numeric-keyed manifest entries), the test would pass while production behavior is wrong. Worth considering whether the test should instead exercise collectRouteClassificationManifest + buildGenerateBundleReplacement (the actual production path) rather than the now-vestigial classifyAllRouteLayouts.

Observations

  • The previous reviewer's concern about a "dead routeIdx property" was a misread. There is no routeIdx property emitted on route objects — routeIdx is the Array.map callback index used only to generate the __VINEXT_CLASS(N) call expression. The generated code is correct and lean.

  • The buildReasonsReplacement being tested-but-uncalled is a reasonable choice given it's consumed by #843. The author's response (keeping it here so #843 imports tested code) makes sense for the stacked PR workflow.

  • The dynamic shim set (headers, server, cache) covers cookies() (in headers.ts), connection() (in server.ts), and cache APIs. This looks complete for the current API surface.

}
}

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.

Minor: the value binding from the destructured [layoutIdx, value] is only used as { kind: value, reason } on line 129. Since it's always "static" | "dynamic" (the Layer1Class type), this is fine — but it shadows the broader concept of "value" in this function. Consider naming it kind directly:

Suggested change
for (const [layoutIdx, value] of route.layer1) {
for (const [layoutIdx, kind] of route.layer1) {

and then line 129 would read merged.set(layoutIdx, { kind, reason }) which is self-documenting.

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.

Renamed in 6827a50mergeLayersForRoute now destructures [layoutIdx, kind] and uses { kind, reason } shorthand on the merge.

Comment on lines +118 to +119
*
* @internal Not called by production code. The `generateBundle` hook in
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 @internal annotation is good, but there's a subtle risk: this function's Layer 1 → Layer 2 composition uses string-keyed layout IDs ("layout:/blog") while the actual production path in generateBundle uses numeric layout indices via the manifest. If the two composition strategies ever diverge (e.g., the merge priority logic changes in mergeLayersForRoute but not here), the layout-classification.test.ts tests would still pass while production is broken.

Not blocking — the composition logic is simple enough today that the drift risk is low. But worth noting for future reference: if the merge logic grows, the tests should shift to exercise the production codepath (collectRouteClassificationManifestbuildGenerateBundleReplacement).

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.

Noted. The drift risk is real — once the merge logic grows beyond the current Layer-1-wins rule, the unit tests need to migrate onto collectRouteClassificationManifest + buildGenerateBundleReplacement so the assertions exercise the production composition rather than the string-keyed test path. Tracking for the next PR in this stack.

);
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.

The stubRe is used for both .test() (line 1697) and .replace() (here). Since .test() advances the regex's lastIndex for regexes with the g flag, and this regex doesn't have the g flag, the two uses are safe. But worth noting that the .replace() call here will only replace the first match — which is correct since the earlier check enforces exactly one chunk with the stub body. Good.

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.

Confirmed. The regex has no g flag, so .test() does not advance lastIndex, and the upstream presence/uniqueness check guarantees .replace() only ever rewrites the single stub chunk. Leaving as-is.

Comment thread packages/vinext/src/index.ts Outdated
Comment on lines +1723 to +1725
const canonicalize = (p: string): string => tryRealpathSync(p) ?? p;

const dynamicShimPaths: ReadonlySet<string> = new Set(
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 canonicalize closure and dynamicShimPaths set are reconstructed on every generateBundle invocation. Since shimsDir is stable for the plugin's lifetime and tryRealpathSync is deterministic for a given filesystem state, both could be hoisted to plugin initialization.

Not blocking — generateBundle runs once per build, so the cost is negligible. But if you end up touching this area again, hoisting would make the hook body a bit leaner.

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.

Hoisted in 6827a50. canonicalize and dynamicShimPaths now live next to the shimsDir definition at plugin init, and the generateBundle hook just references them.

built = await buildMinimalFixture();
}, 120_000);

afterAll(() => {
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 afterAll is a deliberate no-op (leaves tmpdirs for debugging). This is fine for CI, but worth noting that buildMinimalFixture creates two temp directories (the fixture dir and the output dir) that accumulate on local dev machines. The route-classification-manifest.test.ts file cleans up in afterEach — the difference in cleanup strategy between the two test files is intentional (integration tests leave artifacts, unit tests don't) but not documented.

Consider adding a brief comment like // Intentionally different from unit tests — integration artifacts are useful for post-mortem debugging.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 20, 2026

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

Key finding:

  • classifyAllRouteLayouts is now vestigial production code — it's annotated @internal and only used by tests, but the production path (generateBundlecollectRouteClassificationManifestbuildGenerateBundleReplacement) uses a different composition strategy (numeric layout indices vs string-keyed layout IDs). If the two strategies diverge, tests would pass while production is wrong. Not blocking, but worth tracking.

Corrections to prior reviews:

  • The previous reviewer's concern about a "dead routeIdx property" was a misread — there is no routeIdx property on route objects. It's only the Array.map callback index used in the __VINEXT_CLASS(N) call expression.

Inline comments (5):

  1. Suggest renaming the value binding to kind in the mergeLayersForRoute loop for clarity
  2. Note about the divergence risk between classifyAllRouteLayouts test path and the production generateBundle path
  3. Observation that the stubRe regex usage (.test() then .replace()) is safe since it lacks the g flag
  4. Minor: canonicalize and dynamicShimPaths could be hoisted to plugin init (non-blocking)
  5. Note about the intentional cleanup strategy difference between integration and unit tests

github run

…inding

`canonicalize` and `dynamicShimPaths` were constructed inside the RSC
generateBundle hook, so they were rebuilt on every invocation even though
shimsDir is fixed for the plugin's lifetime and tryRealpathSync is
deterministic against the package's own shim files. The cost is small
because generateBundle runs once per build, but the hook body grew long
enough that hoisting the closure and the shim-path Set up next to the
shimsDir definition makes the bundle hook read more like the request-time
behaviour it is wiring up.

Separately, mergeLayersForRoute destructured Layer 1 entries as
[layoutIdx, value] and then wrote { kind: value, reason }. Since the
binding is always the Layer1Class kind, renaming it to `kind` lets the
merge use object-shorthand and removes the value-vs-kind shadowing.

No behaviour change; same realpath calls, same merge result, same
generated dispatch table. Targeted unit and integration suites still pass.
@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-designed infrastructure. The layered classification pipeline is clean, the codegen bridge is correctly factored, and the generateBundle hook's defensive error handling (stub-body drift detection, scan-phase skip, single-chunk enforcement) is genuinely good engineering. The caching strategy (sourceCache for Layer 1 file reads, graphCache for Layer 2 module-graph traversals) correctly deduplicates the two most expensive operations. Test coverage is thorough — the integration test that builds through the full Vite pipeline and evaluates the emitted dispatch function is the right level of verification for this kind of codegen work.

Previous reviews have been comprehensive. Most substantive concerns have been addressed (manifest clearing, source map nulling, hoisting canonicalize/dynamicShimPaths to plugin init, stub regex comments). I have a few remaining observations — one correctness concern on the mergeLayersForRoute Layer 2 reason, and a few minor items.

Substantive

  1. Layer 2 reason objects are reconstructed on every mergeLayersForRoute call rather than being carried from the actual BFS traversal. The hardcoded { layer: "module-graph", result: "static" } reason in mergeLayersForRoute (line 115-118 of route-classification-manifest.ts) discards any firstShimMatch that the classifier may have found. This doesn't matter for Layer 2 "static" results today (they never have a firstShimMatch), but it means the merge function silently drops diagnostic data for any future Layer 2 variant that carries it. See inline comment.

  2. buildGenerateBundleReplacement and buildReasonsReplacement are near-duplicates. They share the same manifest iteration, merge logic, sort, and switch-case codegen structure — only the map entry serialization differs. This is acceptable for now given the small surface, but if a third variant appears (or the merge logic changes), these should be unified into a single function parameterized by the entry serializer.

Minor

  1. The needs-probe kind in LayoutBuildClassification is defined but never constructed by any code path in this PR. classifyAllRouteLayouts maps "needs-probe" from the module-graph result directly, but the production path in generateBundle filters these out before they reach buildGenerateBundleReplacement. The type is correct (it documents a possible Layer 2 outcome), but no test exercises a { kind: "needs-probe", reason } flowing through mergeLayersForRoute. The existing layout-classification.test.ts covers the classifyAllRouteLayouts path, which does produce needs-probe — but that's the test-only path, not the production path.

  2. Overall this is solid infrastructure that cleanly separates the classification pipeline from the (still-evolving) skip mechanism. The runtime consumer in app-page-execution.ts correctly short-circuits on build-time results while still probing for special errors (redirects, not-found), which is the right trade-off. Ready to land with or without the inline suggestions below.

merged.set(layoutIdx, {
kind: "static",
reason: { layer: "module-graph", result: "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 Layer 2 reason here is reconstructed as a fresh object literal rather than being passed through from the actual classifyLayoutByModuleGraph call. This works today because Layer 2 "static" results never carry a firstShimMatch, but it means the merge function is the only place that constructs Layer 2 reason objects — if a caller ever passes richer Layer 2 data through layer2PerRoute, the diagnostic information would be silently dropped.

This is fine for this PR since layer2PerRoute is typed as Map<number, "static"> (the reason can only ever be { layer: "module-graph", result: "static" }). But if layer2PerRoute ever carries a reason alongside the classification, this is the spot that would need updating. Non-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.

Fixed in c03ed6bd.

layer2PerRoute now carries typed module-graph reason objects instead of lossy "static" markers, and mergeLayersForRoute() consumes those reasons directly instead of reconstructing them. I also factored the shared dispatch generation into buildRouteDispatchReplacement() so the classification and reason emitters stop drifting independently.

Validation: vp test run tests/route-classification-manifest.test.ts tests/build-time-classification-integration.test.ts.

" default: return null;",
" }",
" }",
].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.

Nit: the generated arrow function wraps the switch in an IIFE pattern:

function __VINEXT_CLASS(routeIdx) { return ((routeIdx) => { switch... })(routeIdx); }

The double-invocation (outer function + inner arrow) is harmless but adds one unnecessary closure allocation per module load. An alternative codegen shape would be to emit the switch body directly:

function __VINEXT_CLASS(routeIdx) { switch (routeIdx) { case 0: ...; default: return null; } }

This avoids the intermediate arrow function. Non-blocking — the dispatch runs once at module load, so the perf difference is negligible. But it would simplify the generated output and make the integration test's regex extraction easier.

const moduleInfo = {
getModuleInfo: (moduleId: string) => {
const info = this.getModuleInfo(moduleId);
if (!info) 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.

Good adapter pattern. One observation: info.importedIds ?? [] and info.dynamicallyImportedIds ?? [] use nullish coalescing, but Rolldown's getModuleInfo returns these as arrays (never null/undefined) for modules that exist in the graph. The ?? [] is defensive, which is fine, but it also means that if getModuleInfo returns a module with importedIds: undefined (indicating a malformed or partially-loaded module), the BFS would silently treat it as a leaf node rather than flagging it. This is the correct behavior for the classifier (conservative = safe), just noting it.

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

This is a critical guard. The comment explains the reasoning well — classifyLayoutByModuleGraph returns "static" for an empty traversal (a module with no imports is trivially static), so without this check, a layout that simply isn't in the module graph (e.g., tree-shaken out, or not yet resolved) would be falsely classified as static. Good.

// Map<layoutIndex, "static" | "dynamic"> for each route. Until the
// plugin patches this stub, every route falls back to the Layer 3
// runtime probe, which is the current (slow) behaviour.
function __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.

Minor: the stub function signature function __VINEXT_CLASS(routeIdx) has no type annotation on the parameter or return type. Since this is inside a generated template string that goes through the RSC build, type annotations won't survive bundling anyway. But if the generated entry is ever type-checked during development (e.g., via the Vite dev server's TypeScript transform), the implicit any parameter could produce a lint warning depending on the project's strictness settings.

Non-blocking — the function only exists until generateBundle replaces it.


beforeAll(async () => {
built = await buildMinimalFixture();
}, 120_000);
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 120s timeout is appropriate for a full createBuilder + buildApp() cycle. Worth noting that if this test runs alongside other integration tests that also call buildApp(), the Vite deps optimizer cache can cause flaky lock contention (the reason the test suite runs serially). Since this test creates its own isolated tmpdir, it should be safe from that, but the symlinked node_modules means the optimizer cache directory is shared with the workspace. If flakiness appears, an isolated cacheDir in the Vite config would fix it.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 20, 2026

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

Key findings:

  1. Layer 2 reason reconstruction in mergeLayersForRoute — The merge function hardcodes { layer: "module-graph", result: "static" } rather than carrying the actual BFS traversal result. This is correct today (Layer 2 "static" results never carry firstShimMatch), but is a subtle coupling that could silently drop diagnostic data if the Layer 2 type evolves.

  2. Near-duplicate codegen functionsbuildGenerateBundleReplacement and buildReasonsReplacement share identical structure (manifest iteration, merge, sort, switch-case generation) with only the entry serializer differing. Acceptable at this scale, flagged for future unification.

  3. needs-probe kind untested through production path — The type exists and is exercised through classifyAllRouteLayouts (test-only), but the production generateBundle path filters these out before they reach the merge function.

  4. Minor items — IIFE codegen pattern adds an unnecessary closure, untyped stub parameter, integration test timeout and optimizer cache sharing considerations.

Positive observations:

  • Defensive error handling (stub drift detection, scan-phase skip, single-chunk enforcement) is strong
  • Caching strategy correctly deduplicates both Layer 1 file reads and Layer 2 graph traversals
  • The critical moduleInfo null guard at line 1769 prevents false "static" classification for unresolved modules
  • Runtime consumer correctly short-circuits on build-time results while still probing for special errors
  • Previous review feedback has been thoroughly addressed

github run

@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 is clean, the generateBundle hook has good defensive error handling (stub-drift detection, scan-phase skip, single-chunk enforcement), and the caching strategy correctly deduplicates both Layer 1 file reads and Layer 2 module-graph traversals. The integration test that builds through the full Vite pipeline and vm.runInThisContext-evaluates the emitted dispatch function is the right level of verification for codegen work.

I have one correctness concern, one structural issue, and a few minor observations.

Issues

  1. ModuleGraphStaticReason is duplicated across two modules — The same type shape { layer: "module-graph"; result: "static"; firstShimMatch?: string } is defined independently in both index.ts:111 and route-classification-manifest.ts:97. These are not shared via import — they're structurally identical but disconnected. If one evolves (e.g., a new diagnostic field is added to the manifest version), the other won't get the update, and the compiler won't catch it since the types only need structural compatibility at call sites. This should be a single exported type from layout-classification-types.ts or route-classification-manifest.ts.

  2. The IIFE wrapper in the patched __VINEXT_CLASS adds an unnecessary closure — The generateBundle hook produces:

    function __VINEXT_CLASS(routeIdx) { return ((routeIdx) => { switch... })(routeIdx); }

    The outer function already receives routeIdx, so the inner arrow function's parameter shadows it unnecessarily. This could be simplified to emit the switch body directly inside __VINEXT_CLASS, avoiding one closure allocation per module load. The perf impact is negligible (runs once), but the generated output would be cleaner and easier to debug.

Minor observations

  1. The serializeReasonExpression function handles all four ClassificationReason variants (runtime-probe, no-classifier) even though only segment-config and module-graph are reachable from the build-time pipeline. The comment on lines 154-160 explains this well — it's for type exhaustiveness and forward compatibility with #843. Acceptable.

  2. The classifyAllRouteLayouts function is now explicitly @internal and test-only, while production uses collectRouteClassificationManifest + buildGenerateBundleReplacement with numeric indices. The two paths share classifyLayoutByModuleGraph but have different merge strategies (string-keyed layout IDs vs. numeric-keyed manifest entries). This is fine today since the merge logic is simple (Layer-1-wins), but if it grows, the test-only path could silently diverge from production. The @internal annotation documents this well.

  3. The integration test's afterAll intentionally leaves temp directories for post-mortem debugging, while the unit test (route-classification-manifest.test.ts) cleans up in afterEach. The difference in cleanup strategy is intentional and reasonable.

Comment thread packages/vinext/src/index.ts Outdated
layer: "module-graph";
result: "static";
firstShimMatch?: string;
};
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 type is structurally identical to ModuleGraphStaticReason in route-classification-manifest.ts:97. Having two independent definitions of the same type means they can drift independently — if someone adds a field to one, the other won't get it, and the compiler only checks structural compatibility at call sites (which may not exercise all fields).

Consider exporting ModuleGraphStaticReason from route-classification-manifest.ts (or from layout-classification-types.ts alongside the other shared types) and importing it here.

Suggested change
};
import type {
ModuleGraphStaticReason,
} from "./build/route-classification-manifest.js";

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.

Fixed in 22a17313.

ModuleGraphStaticReason now lives in layout-classification-types.ts as the shared leaf type, and both route-classification-manifest.ts and index.ts import it instead of carrying duplicate local definitions.

Validation: vp test run tests/route-classification-manifest.test.ts tests/build-time-classification-integration.test.ts.

rscClassificationManifest,
layer2PerRoute,
);
const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(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 generated replacement wraps the switch in an IIFE:

function __VINEXT_CLASS(routeIdx) { return ((routeIdx) => { switch... })(routeIdx); }

The inner arrow function's routeIdx parameter shadows the outer function's routeIdx, adding one unnecessary closure allocation. A simpler codegen shape would emit the switch body directly:

function __VINEXT_CLASS(routeIdx) { switch (routeIdx) { case 0: ...; default: return null; } }

This would require buildRouteDispatchReplacement to emit a switch statement body rather than a self-contained arrow function expression. Non-blocking since __VINEXT_CLASS runs once at module load, but it would simplify both the generated output and the integration test's regex extraction.

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.

Left as a follow-up.

I agree with the shape concern, but rewriting buildRouteDispatchReplacement() to emit statement-form switch bodies would change both buildGenerateBundleReplacement() and buildReasonsReplacement() plus the integration-test extraction path. That is a broader codegen refactor than I want to fold into this review-fix pass, especially with #843 depending on the shared dispatcher shape.

For this PR I limited the patch to the correctness/maintainability issues above; the extra closure here is non-blocking since __VINEXT_CLASS runs once at module load.

Comment thread packages/vinext/src/index.ts Outdated
graphCache.set(layoutModuleId, graphResult);
}
if (graphResult.result === "static") {
perRoute.set(layoutIdx, { layer: "module-graph", result: graphResult.result });
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: the ModuleGraphStaticReason object is reconstructed here with { layer: "module-graph", result: graphResult.result } rather than using the moduleGraphReason() helper from layout-classification.ts (which exists specifically to build these reason objects). Using the helper would centralize reason construction:

Suggested change
perRoute.set(layoutIdx, { layer: "module-graph", result: graphResult.result });
perRoute.set(layoutIdx, moduleGraphReason(graphResult));

This requires importing moduleGraphReason from layout-classification.ts and narrowing graphResult to { result: "static" } (which the if guard on line 1784 already ensures). The overloaded signature on moduleGraphReason already narrows the return type for result: "static" inputs.

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.

Fixed in 22a17313.

generateBundle now uses moduleGraphReason(graphResult) again, gated by an explicit isStaticModuleGraphResult() type guard so the call site stays type-safe without reconstructing the object literal.

Validation: vp test run tests/route-classification-manifest.test.ts tests/build-time-classification-integration.test.ts.

Comment on lines +193 to +200
return [
"(routeIdx) => {",
" switch (routeIdx) {",
...cases,
" default: return null;",
" }",
" }",
].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.

The codegen emits the dispatch as an arrow function expression wrapped in an IIFE call. For the buildGenerateBundleReplacement case (which only serializes "static" or "dynamic" strings), the generated output looks like:

(routeIdx) => {
    switch (routeIdx) {
      case 0: return new Map([[0, "dynamic"]]);
      default: return null;
    }
  }

This is clean. But after index.ts:1797 wraps it in function __VINEXT_CLASS(routeIdx) { return (<this>)(routeIdx); }, the double-invocation emerges. Consider whether buildRouteDispatchReplacement could instead emit a statement-form body (just the switch) that gets inlined directly into the function, eliminating the IIFE pattern entirely. This would be a cross-cutting change affecting both buildGenerateBundleReplacement and buildReasonsReplacement, so probably better as a follow-up.

Comment on lines +1759 to +1760
const layer2PerRoute = new Map<number, Map<number, ModuleGraphStaticReason>>();
const graphCache = new Map<string, ReturnType<typeof classifyLayoutByModuleGraph>>();
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: graphCache is keyed by layoutModuleId (the canonicalized path), so shared layouts across routes are classified once. This correctly addresses the O(routes × shared-layouts) concern from earlier reviews.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 20, 2026

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

Key findings:

  1. ModuleGraphStaticReason type duplication — The same type is independently defined in both index.ts:111 and route-classification-manifest.ts:97. These are structurally identical but disconnected, meaning they can drift independently without compiler errors. Should be a single exported type.

  2. Unnecessary IIFE wrapper in generated code — The generateBundle hook wraps the dispatch arrow function in function __VINEXT_CLASS(routeIdx) { return ((routeIdx) => { switch... })(routeIdx); }, creating an unnecessary closure where the switch could be inlined directly. Non-blocking since it runs once at module load.

  3. Reason object reconstruction in generateBundle — The moduleGraphReason() helper exists specifically for constructing module-graph reason objects, but index.ts:1785 manually reconstructs the object literal instead of using it.

Positive observations:

  • The graphCache correctly deduplicates Layer 2 BFS traversals for shared layouts
  • The stub-drift detection (throw when __VINEXT_CLASS is referenced but stub body not found) is a good defensive pattern
  • The @internal annotation on classifyAllRouteLayouts clearly documents the divergence between test and production paths
  • The integration test's vm.runInThisContext approach for evaluating the dispatch function is the right technique
  • Source map nulling and manifest clearing after consumption address prior review feedback

github run

@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-designed infrastructure. The layered classification pipeline (segment-config → module-graph → runtime probe) is cleanly factored, the type system is well-structured, and the defensive generateBundle hook has exactly the right error semantics. The PR has been through several rounds of review already, and the author has addressed all substantive feedback (type deduplication fix, manifest clearing, source map nulling, canonicalize/dynamicShimPaths hoisting, moduleGraphReason() usage, layer2PerRoute reason carrying).

The code is correct and the test coverage is thorough — 44 unit tests for the manifest helpers, 5 integration tests through the full Vite build pipeline, plus updated tests for the classifier and entry templates.

Remaining observations (non-blocking)

  1. IIFE wrapper in generated code — The generated __VINEXT_CLASS wraps the dispatch arrow in an IIFE (function __VINEXT_CLASS(routeIdx) { return ((routeIdx) => { switch... })(routeIdx); }). The author has acknowledged this and deferred the codegen simplification to avoid a cross-cutting change in this PR. Acceptable — it runs once at module load.

  2. classifyAllRouteLayouts test-only divergence risk — The @internal annotation documents that production uses collectRouteClassificationManifest + buildGenerateBundleReplacement (numeric indices) while classifyAllRouteLayouts uses string-keyed layout IDs. The author acknowledged the drift risk and is tracking it. The merge logic is simple enough today (Layer-1-wins) that the risk is low.

  3. buildReasonsReplacement tested but not called in production — This is intentional infrastructure for #843. The author's rationale (keeping it here so #843 imports tested code rather than landing untested code) is sound for a stacked PR workflow.

No blocking issues remain. Approving.

outcome: "static" | "dynamic";
error?: string;
}
| { layer: "no-classifier" };
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 { layer: "no-classifier" } variant of ClassificationReason has no producer in the build-time pipeline and no consumer in this PR. It's only reachable via the serializeReasonExpression exhaustiveness arm. Is this used by the runtime probe path in app-page-execution.ts, or is it purely forward-looking for #843?

Not asking for a change — just noting that the type is broader than its current production usage, which is fine for a discriminated union designed for extensibility.

// 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*\}/;
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 comments explaining both the semicolon tolerance and the entry-chunk rename assumption. The regex + defensive error handling (stub-body drift detection, scan-phase skip, multi-chunk rejection) is genuinely well-engineered.

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

This guard is essential and the comment explains it well. classifyLayoutByModuleGraph returns { result: "static" } for an empty BFS traversal (a module with zero imports is trivially static), so without this check, a layout that isn't in the module graph at all would be falsely classified as static. Good defensive design.

* says `force-dynamic` must never be demoted to "static" because its module
* graph happened to be clean.
*/
function mergeLayersForRoute(
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 merge function correctly applies Layer 2 first, then overwrites with Layer 1 — a clean way to implement the Layer-1-wins priority rule without branching. Since layer2 only ever contains ModuleGraphStaticReason (typed as such), the overwrite semantics are always correct: Layer 1 dynamic overrides Layer 2 static, and Layer 1 static is idempotent with Layer 2 static.


// Non-greedy match: assumes the inner dispatch body does not contain
// ')(routeIdx)' as a substring. Coupled to the codegen shape in
// route-classification-manifest.ts buildGenerateBundleReplacement.
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: this non-greedy [\s\S]*? regex is coupled to the codegen shape (it assumes no )(routeIdx) substring in the switch body). The comment on lines 54-56 documents this coupling well. If the codegen ever changes to inline the switch body (eliminating the IIFE), this extraction logic would need updating too. The coupling is fine given both are in the same repo.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 20, 2026

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

Verdict: Approved

This PR adds a build-time layout classification pipeline that bridges the segment-config and module-graph classifiers with the RSC entry codegen via a generateBundle hook. The architecture is clean — Layer 1 (segment config) runs during the load hook, Layer 2 (module graph BFS) runs in generateBundle when Rolldown's module info is available, and the stub __VINEXT_CLASS function is patched with a real dispatch table.

Key strengths:

  • Defensive error handling in generateBundle (stub-body drift detection, scan-phase skip, multi-chunk rejection)
  • Critical guard at index.ts:1774 preventing false "static" classification for modules not in the graph
  • Clean Layer-1-wins merge strategy (apply Layer 2 first, overwrite with Layer 1)
  • Thorough test coverage including a full-pipeline integration test that builds through createBuilder + buildApp()

Non-blocking observations noted in inline comments:

  1. The { layer: "no-classifier" } reason variant has no current producer — forward-looking for chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6] #843
  2. The integration test's regex extraction is coupled to the IIFE codegen shape (documented)
  3. The IIFE wrapper and classifyAllRouteLayouts test-only divergence risk are tracked for follow-up

All substantive issues from prior reviews have been addressed in the latest commits.

github run

// 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*\}/;
Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx Apr 20, 2026

Choose a reason for hiding this comment

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

To be honest I'm not the biggest fan of this approach. It feels a bit brittle.

It would be worth us finding another way to deal with this going forward - probably worth making an issue to track @NathanDrake2406 .

@james-elicx james-elicx merged commit bc03bf1 into cloudflare:main Apr 20, 2026
33 checks passed
@NathanDrake2406 NathanDrake2406 deleted the feat/pr-768-5-build-classification-wire branch April 21, 2026 05:03
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