Skip to content

chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6]#843

Open
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:chore/pr-768-6-classification-debug-reasons
Open

chore(debug): classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION [6/6]#843
NathanDrake2406 wants to merge 2 commits intocloudflare:mainfrom
NathanDrake2406:chore/pr-768-6-classification-debug-reasons

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 14, 2026

Summary

PR 6 of 6, restack of #768. Previously stacked on #842; now rebased directly onto main after #842 merged.

Operators tracing why a layout was flagged static or dynamic can opt in with VINEXT_DEBUG_CLASSIFICATION=1 at build time. When active, the plugin patches a second stub (__VINEXT_CLASS_REASONS) with a dispatch table that returns per-layout ClassificationReason structures. The runtime probe loop emits a debug line per layout when debugClassification is wired, and the hot path remains allocation-free when debug is off because the stub returns null.

The reasons machinery (types, report.ts tagged results, and buildReasonsReplacement) already shipped in #842 as tested-but-uncalled infrastructure. This PR wires it up end-to-end.

What changes

  • index.ts generateBundle hook reads VINEXT_DEBUG_CLASSIFICATION at build time; when set, it calls buildReasonsReplacement and patches the __VINEXT_CLASS_REASONS stub alongside __VINEXT_CLASS. Runs before the existing target.map = null / manifest-clear so both inputs are still available.
  • Stub-drift detection extended: when build-time debug is on, a missing __VINEXT_CLASS_REASONS stub throws loudly, matching the existing policy for __VINEXT_CLASS.
  • Generated RSC entry emits the __VINEXT_CLASS_REASONS null stub and an env-gated __classDebug logger. Each route's __buildTimeReasons field is gated __classDebug ? __VINEXT_CLASS_REASONS(routeIdx) : null, so at module load the reasons Map is only constructed when runtime debug is also on.
  • app-page-execution.ts accepts optional buildTimeReasons and debugClassification in LayoutClassificationOptions. The probe loop emits one debug line per layout across all three outcome paths: build-time classified, runtime probe success, runtime probe error. The no-classifier variant has a single documented producer here, the ?? fallback when a build-time classified layout has no attached reason payload.
  • Tests: new gated-on and gated-off cases in app-page-execution.test.ts; integration test asserts the __classDebug gating expression and the null stub when build-time debug is off.

Combinations

Both env checks are independent and all four combinations are safe:

build-time runtime behaviour
off off stub stays return null, __classDebug undefined, zero cost
on off stub patched but never called (__classDebug undefined)
off on stub stays return null, reasons map is null, fallback emits { layer: "no-classifier" }
on on full reason payload per layout

Stack

  1. [1/6] refactor(app-rsc-entry): centralize request-derived page inputs [1/6] #838, merged, centralize request-derived page inputs
  2. [2/6] feat(app-router): emit per-layout flags in the RSC payload [2/6] #839, merged, emit per-layout flags
  3. [3/6] feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6] #840, closed, filter skipped layouts. The 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 around render-time omission rather than byte-level filtering.
  4. [4/6] feat(app-router): send X-Vinext-Router-Skip on navigation requests [4/6] #841, closed, send X-Vinext-Router-Skip. Closed for the same reason as feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6] #840.
  5. [5/6] feat(build): wire build-time layout classification into RSC entry #842, merged, wire build-time layout classification into the RSC entry
  6. [6/6] this PR, classification reasons sidecar

Validation

Run locally via vp test run <file>:

  • tests/app-page-execution.test.ts (17 cases, including 3 new debug-gated cases)
  • tests/build-time-classification-integration.test.ts (7 cases, including 2 new reasons-sidecar cases)
  • tests/entry-templates.test.ts (snapshot regenerated)
  • tests/app-router.test.ts
  • vp check clean

Risks / follow-ups

@NathanDrake2406 NathanDrake2406 force-pushed the chore/pr-768-6-classification-debug-reasons branch from 5355568 to 54296aa Compare April 15, 2026 03:48
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 15, 2026

Open in StackBlitz

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

commit: dcab0c5

@NathanDrake2406 NathanDrake2406 force-pushed the chore/pr-768-6-classification-debug-reasons branch 2 times, most recently from f2fa472 to 8fbefec Compare April 20, 2026 15:47
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 20, 2026 15:50
Copilot AI review requested due to automatic review settings April 20, 2026 15:50
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

Adds an opt-in (via VINEXT_DEBUG_CLASSIFICATION) “classification reasons” sidecar to the generated RSC entry so operators can see why each layout was classified static/dynamic, while keeping the default hot path allocation-free.

Changes:

  • Patch a new __VINEXT_CLASS_REASONS stub in generateBundle when VINEXT_DEBUG_CLASSIFICATION is set, emitting a per-route reasons dispatch table.
  • Extend the generated RSC entry to gate __buildTimeReasons behind __classDebug, and pass buildTimeReasons + debugClassification into the runtime probe.
  • Emit one debug reason per layout in probeAppPageLayouts, with new tests and updated entry template snapshots.

Reviewed changes

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

Show a summary per file
File Description
packages/vinext/src/index.ts Conditionally patches __VINEXT_CLASS_REASONS dispatch in generateBundle behind the debug env var.
packages/vinext/src/entries/app-rsc-entry.ts Emits __classDebug, __VINEXT_CLASS_REASONS stub, and threads reasons/debug hook through the route table into the probe.
packages/vinext/src/server/app-page-execution.ts Wires buildTimeReasons + debugClassification and emits structured per-layout debug reasons (build-time + runtime-probe).
tests/app-page-execution.test.ts Adds coverage for debug-gated behavior and runtime-probe reason emission.
tests/build-time-classification-integration.test.ts Updates route-index extraction regex and asserts reasons sidecar is gated + stub stays null when debug is off.
tests/snapshots/entry-templates.test.ts.snap Snapshot updates for the new debug logger, reasons stub, and route table wiring.

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

Comment on lines +37 to +39
* Per-layout classification reasons keyed by layout index. Populated by the
* generator only when `VINEXT_DEBUG_CLASSIFICATION` is set at runtime; the
* hot path never reads this and the wire payload is unchanged.
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

The docblock for buildTimeReasons says it is populated only when VINEXT_DEBUG_CLASSIFICATION is set at runtime, but the reasons dispatch table is actually only patched into the bundle when the env var is present at build time (generateBundle). Consider updating this comment to reflect the build-time + runtime gating so operators don’t set the flag only at runtime and then get no-classifier reasons unexpectedly.

Suggested change
* Per-layout classification reasons keyed by layout index. Populated by the
* generator only when `VINEXT_DEBUG_CLASSIFICATION` is set at runtime; the
* hot path never reads this and the wire payload is unchanged.
* Per-layout classification reasons keyed by layout index. Available only
* when `VINEXT_DEBUG_CLASSIFICATION` was enabled at build time so the
* reasons dispatch table was patched into the bundle, and populated by the
* generator only when the same flag is also set at runtime. The hot path
* never reads this and the wire payload is unchanged.

Copilot uses AI. Check for mistakes.
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.

Addressed in f33d77b. The docblock now covers both lifecycle points — build-time for the stub patch, runtime for the route to read it — and calls out that runtime-only activation falls through to no-classifier.

Comment on lines +216 to +222
it("leaves __VINEXT_CLASS_REASONS as a null stub when build-time debug is off", () => {
expect(built.chunkSource).toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/,
);
expect(built.chunkSource).not.toMatch(
/function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{[^}]*switch/,
);
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

These new assertions assume VINEXT_DEBUG_CLASSIFICATION is unset during the fixture build. If the env var is set in the ambient test runner/CI, the plugin will patch __VINEXT_CLASS_REASONS and this test will fail. Consider making the test hermetic by explicitly saving/clearing process.env.VINEXT_DEBUG_CLASSIFICATION around the buildMinimalFixture() call (and restoring it) so the expected “debug off” state is guaranteed.

Copilot uses AI. Check for mistakes.
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.

Addressed in f33d77b. buildMinimalFixture now saves, overrides, and restores process.env.VINEXT_DEBUG_CLASSIFICATION around builder.buildApp() so the "debug off" assertions hold even on a developer machine that has the flag set in the ambient shell, and the new { debug: true } suite below exercises the patched path without polluting the sibling suite.

Comment on lines +1811 to +1818
if (enableClassificationDebug) {
const reasonsReplacement = buildReasonsReplacement(
rscClassificationManifest,
layer2PerRoute,
);
const patchedReasonsBody = `function __VINEXT_CLASS_REASONS(routeIdx) { return (${reasonsReplacement})(routeIdx); }`;
target.code = target.code.replace(reasonsStubRe, patchedReasonsBody);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

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

The new enableClassificationDebug branch patches __VINEXT_CLASS_REASONS, but there doesn’t appear to be a test asserting the “debug on” build output (i.e., that __VINEXT_CLASS_REASONS is replaced with a populated switch/dispatch and returns a per-route Map of reasons). Adding an integration test that sets VINEXT_DEBUG_CLASSIFICATION for the fixture build and validates the emitted dispatcher would help prevent regressions in this path.

Copilot uses AI. Check for mistakes.
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.

Addressed in f33d77b, and further strengthened in dcab0c5. The build-time classification integration (debug on) describe block now runs buildMinimalFixture({ debug: true }), evaluates the patched __VINEXT_CLASS_REASONS via extractReasonsDispatch, and asserts per-route reason entries. dcab0c5 extends that to cover both Layer 1 (segment-config) and Layer 2 (module-graph with result: "static") in the same /force-dyn dispatch entry.

…CLASSIFICATION

Operators tracing why a layout was flagged static or dynamic can opt in
with VINEXT_DEBUG_CLASSIFICATION=1 at build time. When active, the
plugin patches a second stub (__VINEXT_CLASS_REASONS) with a dispatch
table that returns per-layout ClassificationReason structures. The
runtime probe loop emits a debug line per layout when
debugClassification is wired, and the hot path remains allocation-free
when debug is off because the stub returns null.

The reasons machinery (types, report.ts tagged results, and
buildReasonsReplacement) already shipped in the previous PR; this PR
just enables the emission path and wires the debug consumer into the
runtime probe.
@NathanDrake2406 NathanDrake2406 force-pushed the chore/pr-768-6-classification-debug-reasons branch from 8fbefec to f33d77b Compare April 20, 2026 16:21
@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

Clean PR that wires up the reasons sidecar end-to-end. The four-combination gating table is well thought out, and the buildTimeReasons field being lazily evaluated behind __classDebug ? keeps the hot path genuinely zero-cost. The test coverage is solid for the unit-level probe behavior and the integration-level codegen shape.

A few comments below, none blocking.

layer: "runtime-probe",
outcome: "dynamic",
error: error instanceof Error ? error.message : String(error),
});
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 error-path debug emission isn't covered by a test. The success case (runtime-probe with outcome: "dynamic" / "static") and the build-time case are both tested, but there's no test where runWithIsolatedDynamicScope throws and debugClassification is set — so this branch with the error field is untested.

Worth adding a quick case that mirrors the runtime-probe test but has runWithIsolatedDynamicScope reject, then asserts the debug callback receives { layer: "runtime-probe", outcome: "dynamic", error: "..." }.

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 dcab0c5. Added emits runtime-probe reason with the error message when the probe throws which throws from inside runWithIsolatedDynamicScope, sets debugClassification, and pins the { layer: "runtime-probe", outcome: "dynamic", error: "headers() outside render" } payload alongside the static neighbour to cover both branches in one pass.

// producer of the variant; see `layout-classification-types.ts`.
cls.debugClassification(
cls.getLayoutId(layoutIndex),
cls.buildTimeReasons?.get(layoutIndex) ?? { 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.

Nit: cls.getLayoutId(layoutIndex) is called twice on the debug path (once for the flag assignment on line 151, once here). It's cheap today since it's just string concatenation, but if this ever becomes non-trivial you'd want to hoist. Not worth changing now, just noting the pattern.

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.

Acknowledged. The second cls.getLayoutId(layoutIndex) is on the debug-gated branch only, so it is free when debug is off. If the helper ever becomes non-trivial, the #863 refactor is the right place to hoist it along with the rest of the dispatch plumbing.

// tracing why a layout was flagged static or dynamic. The reason payload is
// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts.
const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION
? function(layoutId, reason) {
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 __classDebug function parameter types are untyped (layoutId, reason). This is consistent with the existing __isrDebug pattern above, so it's fine for generated code, but noting it since the typed counterpart in LayoutClassificationOptions.debugClassification has full types. If you ever extract this into a helper module per the #863 refactor, the types come for free.

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.

Acknowledged. Keeping it shaped after __isrDebug so the codegen stays uniform. #863 folds both dispatch tables and both debug hooks into a typed module; the types arrive there.

`vinext: build-time classification — expected __VINEXT_CLASS stub in exactly one RSC chunk, found ${chunksWithStubBody.length}`,
);
}
if (enableClassificationDebug && !reasonsStubRe.test(chunksWithStubBody[0]!.chunk.code)) {
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 drift detection for __VINEXT_CLASS_REASONS only fires when enableClassificationDebug is true. This means in a normal (non-debug) build, if the generator stops emitting the reasons stub, nobody notices until someone turns on debug and hits a confusing failure.

Not a bug — the stub is dead code when debug is off, so there's no behavioral impact. But if the #863 virtual-module refactor collapses both stubs, this asymmetry disappears naturally. Just flagging it as a design note.

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, recording the asymmetry rather than fixing it in this PR. The reasons stub is dead code when debug is off, so the silent-miss window is confined to the next operator that enables debug on a build that stopped emitting the stub. #863 collapses both stubs into one typed module and the drift check becomes symmetric by construction.

);
});

it("emits a segment-config reason for the force-dyn layout", () => {
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 that the debug-on suite does a full buildMinimalFixture({ debug: true }). Copilot flagged the absence of a debug-on integration test in index.ts, but this covers it.

One thing that would strengthen coverage: assert that the reasons Map for the root layout (index 0) on the /force-dyn route has a module-graph reason with result: "static", since the root layout is pure JSX and Layer 2 should classify it. That would exercise both Layer 1 and Layer 2 reasons in a single route's dispatch entry.

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 dcab0c5. The debug-on integration test now reads the reasons Map for /force-dyn and asserts layer: "module-graph" + result: "static" on root layout index 0 alongside the existing segment-config assertion on nested layout index 1, so regressing either classifier layer trips the same test. Kept ReasonShape structural so this still survives the #863 codegen reshape.

function extractRouteIndexByPattern(chunkSource: string): Map<string, number> {
const result = new Map<string, number>();
const re = /__buildTimeClassifications:\s*__VINEXT_CLASS\((\d+)\)[^,]*,\s*pattern:\s*"([^"]+)"/g;
const re = /__buildTimeClassifications:\s*__VINEXT_CLASS\((\d+)\)[\s\S]*?pattern:\s*"([^"]+)"/g;
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 fix. The old regex [^,]* broke once __buildTimeReasons was inserted between __buildTimeClassifications and pattern, since commas now appear in that span. The [\s\S]*? non-greedy match is correct here.

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.

Acknowledged. Left as-is.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 20, 2026

Review posted. Here's a summary of the feedback:

Overall: Clean PR. The four-combination gating matrix (build-time on/off x runtime on/off) is well-designed, and the lazy __classDebug ? __VINEXT_CLASS_REASONS(N) : null keeps the hot path allocation-free. No blocking issues.

Key comments:

  1. Missing test for error-path debug emission (app-page-execution.ts:190): The catch branch that emits { layer: "runtime-probe", outcome: "dynamic", error: "..." } has no test coverage. The success and build-time paths are well-tested, but there should be a case where runWithIsolatedDynamicScope throws with debugClassification set.

  2. Drift detection asymmetry (index.ts:1741): The __VINEXT_CLASS_REASONS stub drift check only fires when debug is on. In a normal build, if the generator stops emitting the reasons stub, the failure is silent until someone enables debug. This resolves itself with the Replace regex-based __VINEXT_CLASS stub patching in generateBundle #863 refactor.

  3. Integration test could be stronger (build-time-classification-integration.test.ts:338): Asserting that the root layout (index 0) on /force-dyn gets a module-graph reason with result: "static" would exercise both Layer 1 and Layer 2 reasons in one dispatch entry.

  4. Good regex fix on the extractRouteIndexByPattern regex change from [^,]* to [\s\S]*? — correctly handles the new __buildTimeReasons field between __buildTimeClassifications and pattern.

github run

The runtime-probe catch branch in `probeAppPageLayouts` emits
`{ layer: "runtime-probe", outcome: "dynamic", error: "..." }` when the
isolated dynamic scope throws. That emission had no coverage, so a
regression that stopped including the `error` field, or that skipped the
debug callback entirely on the error path, would not have tripped any
test.

The reasons sidecar integration test also only asserted the Layer 1
(`segment-config`) reason on the nested `force-dynamic` layout. Layer 2
(`module-graph`) on the pure-JSX root layout was exercised in the build
but never read, so a regression that stopped serializing the `result`
field or the `module-graph` discriminator for Layer 2 would slip through.

Extends the existing debug-on describe block to read reasons for layout
index 0 on `/force-dyn` and assert the `module-graph` + `result: "static"`
pair, keeping the structural `ReasonShape` so the test still survives
the cloudflare#863 codegen reshape.

Also removes a pre-existing `as` on the narrowed Map return in favour of
a proper `in`-narrowing type predicate.
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