Skip to content

fix(app-router): expose active source page on window.next - #1995

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-dir-index-parity
Jun 13, 2026
Merged

fix(app-router): expose active source page on window.next#1995
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-dir-index-parity

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Area Details
Goal Match Next.js' App Router window.next.__internal_src_page behaviour.
Core change Carry source-page metadata from app route filesystem segments into the browser router state and mirror committed state onto window.next.
Main boundary The source page must preserve route groups and dynamic bracket segment names, so it cannot be derived from URL route IDs.
Primary files app-page-route-wiring.tsx, app-elements-wire.ts, app-browser-entry.ts, window-next.ts
Expected impact Next-compatible tooling and upstream deploy tests can observe the active source page on initial render and app-router transitions.

Why

Next.js exposes the active app source page as an internal App Router diagnostic field. The invariant is that this value describes the matched source file path, not the visible URL. Vinext already had the window.next shape, but it only carried URL-style route identity through the app payload, which loses route groups like (newroot) and dynamic source segments like [id].

Area Principle / invariant What this PR changes
App route metadata Source-page identity belongs to the route payload because the browser cannot reconstruct it from the URL. Adds __sourcePage to AppElements metadata, omitted for older payloads and validated when present.
Browser router state window.next.__internal_src_page should reflect committed App Router state. Writes the metadata value from treeState.elements in the browser root effect, matching Next.js' committed-tree effect.
Compatibility tests The port must prove route-group and dynamic-segment parity, not just that a field exists. Ports the upstream /dashboard, /dynamic/[category]/[id], and /(newroot)/dashboard/another expectations into focused Vinext tests.

What changed

Scenario Before After
Initial app route load window.next.__internal_src_page stayed unset. It is set to the matched source page, for example /dashboard/page.
Dynamic app route URL route IDs lost the bracketed source segment names. The payload emits /dynamic/[category]/[id]/page.
Route groups URL route IDs omitted groups like (newroot). The payload emits /(newroot)/dashboard/another/page.
Missing or old payload metadata No explicit source-page field existed. Missing __sourcePage reads as null and deletes the window field.
Maintainer review path
  1. packages/vinext/src/server/app-page-route-wiring.tsx builds source-page strings from route filesystem segments.
  2. packages/vinext/src/server/app-elements-wire.ts carries and validates the optional metadata field.
  3. packages/vinext/src/server/app-browser-entry.ts mirrors committed router state to window.next.
  4. packages/vinext/src/client/window-next.ts owns the field write/delete semantics.
  5. tests/app-page-route-wiring.test.ts and tests/shims.test.ts cover the ported upstream contract at the lowest useful boundaries.
Validation
  • vp test run tests/app-elements.test.ts tests/app-page-route-wiring.test.ts tests/shims.test.ts
  • vp check packages/vinext/src/client/window-next.ts packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/server/app-elements-wire.ts packages/vinext/src/server/app-elements.ts packages/vinext/src/server/app-page-element-builder.ts packages/vinext/src/server/app-page-route-wiring.tsx tests/app-elements.test.ts tests/app-page-route-wiring.test.ts tests/shims.test.ts
  • vp run vinext#build
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/app/index.test.ts

The upstream deploy-suite run is file-scoped. The four window.next.__internal_src_page cases are green after this change. The file still exits non-zero on unrelated existing behaviours in redirects, rewrites, layout reuse, back/forward soft navigation, loading fallbacks, searchParams-on-rewrite, template components, and bootstrap script placement.

Risk / compatibility
  • Public API: no new public API. This fills an existing internal window.next field already modeled by Vinext.
  • Payload compatibility: __sourcePage is optional. Missing metadata reads as null and does not break older app payloads.
  • Router/RSC impact: source-page metadata is emitted alongside existing app element metadata and read from committed browser state.
  • Existing-app risk: low. The value is diagnostic/internal and derived from route metadata already used by app routing.
Non-goals
  • This PR does not address the other unrelated failing behaviours in test/e2e/app-dir/app/index.test.ts.
  • This PR does not implement a full Next.js Flight router state extractor because Vinext already has a typed filesystem route graph at the server boundary.

References

Reference Why it matters
Next.js app-dir source-page tests Defines the exact initial-load and transition expectations ported here.
Next.js App Router effect Shows that Next.js mirrors committed router state onto window.next.__internal_src_page.
Next.js source-page extraction Shows that the value is built from source segments and includes page as the terminal segment.
Next.js dynamic segment mapping Shows why dynamic source names must stay bracketed instead of using concrete URL values.

App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions.

The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does.

Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test.
@pkg-pr-new

pkg-pr-new Bot commented Jun 13, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: be4b8b0

@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review June 13, 2026 07:01
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose active source page on window.next

Verified the core logic against the Next.js reference and ran the targeted suites locally.

Correctness against Next.js — looks good. The key invariant (route groups like (newroot) and bracketed dynamic segment names like [id] must be preserved, not derived from the URL) matches Next.js's extractSourcePageSegmentsFromFlightRouterState / segmentToSourcePagePathname in compute-changed-path.ts. Group segments are intentionally not stripped from the source page (only the visible-path normalizeSegments strips them), and catch-all routes keep their [...slug]/[[...slug]] filesystem form — which is exactly what route.routeSegments carries here. The committed-state effect in app-browser-entry.ts also mirrors Next.js's useEffect(..., [state.tree]) that assigns the value or deletes it when undefined.

Validation locally:

  • vp test run tests/app-page-route-wiring.test.ts tests/shims.test.ts tests/app-page-element-builder.test.ts → 1142 passed
  • vp check on the changed source files → clean (format, lint, types)

One consistency nit (non-blocking) noted inline: readSourcePageMetadata throws on a malformed value, which diverges from the established "degrade like a missing field instead of crashing render paths" convention used by readArtifactCompatibilityMetadata right above it. Since readMetadata now runs in the browser commit effect on every navigation and in several server hot paths, a throw here is stricter than the rest of the reader. In practice vinext always emits a valid value, so risk is low — flagging for parity with the surrounding code.

Nice, tightly-scoped change with good test coverage ported from the upstream contract.

throw new Error("[vinext] Invalid __sourcePage in App Router payload: expected absolute path");
}
return 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 consistency note: this reader throws on a malformed __sourcePage, whereas readArtifactCompatibilityMetadata directly above (lines 698-707) deliberately degrades to a default rather than crashing, with a comment explaining that scaffolding metadata should "degrade like missing __layoutFlags instead of crashing render paths that do not read it."

readAppElementsMetadata is now called on every navigation in the browser commit effect (app-browser-entry.ts:1047) and in several server hot paths (app-ssr-entry.ts, app-optimistic-routing.ts, app-browser-client-reuse-manifest.ts), most of which don't read sourcePage. A throw here would break those paths for a payload that's otherwise valid. vinext always emits a valid value today so the risk is low, but consider degrading to null to match the surrounding convention:

Suggested change
}
function readSourcePageMetadata(value: unknown): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== "string" || !value.startsWith("/")) return null;
return value;
}

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my analysis of PR #1995:

Review outcome: Approve with one minor nit

Verified correct against Next.js. The PR's central design decision — that the source page must preserve route groups ((newroot)) and bracketed dynamic segment names ([id], [...slug]) rather than being derived from the visible URL — matches Next.js's extractSourcePageSegmentsFromFlightRouterState/segmentToSourcePagePathname in compute-changed-path.ts. Route groups are intentionally NOT stripped from the source page (only the visible-path normalization strips them), and filesystem segments naturally carry the [...slug]/[[...slug]] form Next.js expects. The browser commit effect mirrors Next.js's useEffect(..., [state.tree]) assign-or-delete semantics.

Validation (local):

  • vp test run on the 3 relevant test files → 1142 passed
  • vp check on all changed source files → clean (format, lint, types)

One non-blocking inline nit: readSourcePageMetadata throws on a malformed value, diverging from the established "degrade gracefully instead of crashing render paths" convention used by the adjacent readArtifactCompatibilityMetadata. Since readMetadata now runs on every browser navigation and in several server hot paths that don't even read sourcePage, I suggested degrading to null for parity. Risk is low in practice because vinext always emits a valid value.

The change is tightly scoped, well-layered (segments flow correctly through the route graph → matching → element builder → wire), and has good test coverage ported from the upstream contract.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose active source page on window.next

Verified the implementation locally and against the Next.js reference. The change is tightly scoped and correct.

Correctness — looks good.

  • The core invariant (source page preserves route groups like (newroot) and bracketed dynamic/catch-all segment names like [id]/[...slug], rather than being derived from the visible URL) is satisfied because routeSegments is built from raw filesystem segments (path.relative(appDir, routeDir) in app-route-graph.ts:2490). This matches Next.js's segmentToSourcePagePathname / extractSourcePageSegmentsFromFlightRouterState in compute-changed-path.ts, where group segments are intentionally not stripped from the source page.
  • The browser commit effect (app-browser-entry.ts:1046) mirrors Next.js's useEffect(..., [state.tree]) assign-or-delete semantics: it writes the value when present and deletes the field when null.
  • __sourcePage is correctly optional on the wire — createMetadataEntries omits it for null/undefined, so older payloads round-trip cleanly and the route: null boundary path emits no field (verified by app-page-boundary-render.test.ts:474).

On the prior nit (readSourcePageMetadata throwing): the earlier review flagged this reader as throwing on malformed input, diverging from readArtifactCompatibilityMetadata's graceful-degrade convention. The current code at app-elements-wire.ts:709 already degrades to null (validated by the degrades malformed optional source-page metadata to null test), so that concern is resolved — no change needed.

Validation (local):

  • vp test run on the 7 affected test files → 1309 passed
  • vp check on all 11 changed source files → clean (format, lint, types)

Minor observation (non-blocking): In app-page-boundary-render.ts:258, options.route.routeSegments is typed optional, so a route with undefined segments would yield /page rather than being omitted. In practice the metadata reader and createAppPageSourcePage's ?? [] fallback keep this benign, and null route already omits the field. Not worth changing.

LGTM.

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my analysis of PR #1995:

Review outcome: Approve (LGTM)

Verified correct against Next.js. The PR's central invariant — that the source page preserves route groups ((newroot)) and bracketed dynamic/catch-all segment names ([id], [...slug]) rather than being derived from the visible URL — is correctly implemented. routeSegments is built from raw filesystem segments (path.relative(appDir, routeDir) in app-route-graph.ts:2490), so the bracketed/grouped directory names flow through verbatim. This matches Next.js's segmentToSourcePagePathname/extractSourcePageSegmentsFromFlightRouterState, where group segments are intentionally not stripped from the source page.

The browser commit effect mirrors Next.js's useEffect(..., [state.tree]) assign-or-delete semantics, and __sourcePage is correctly optional on the wire (omitted for null/undefined), so old payloads round-trip cleanly.

On the prior reviewer's nit about readSourcePageMetadata throwing: the current code at app-elements-wire.ts:709 already degrades to null gracefully (validated by a dedicated test), so that concern is resolved — no change needed. The earlier review appears to have been based on a prior revision.

Validation (local):

  • vp test run on the 7 affected test files → 1309 passed
  • vp check on all 11 changed source files → clean (format, lint, types)

I noted one non-blocking minor observation (a route with undefined segments would yield /page rather than omitting the field), but it's benign in practice. The change is tightly scoped, well-layered, and has good test coverage ported from the upstream contract.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose active source page on window.next

Verified the implementation against the Next.js reference and ran the targeted suites locally. The change is correct, tightly scoped, and well-layered.

Correctness vs Next.js — confirmed. Fetched compute-changed-path.ts from Next.js canary. The PR's central invariant is exactly right:

  • segmentToSourcePagePathname preserves route groups (group segments are not stripped here — only normalizeSegments strips them for the visible path) and emits bracketed dynamic/catch-all forms ([id], [...slug], [[...slug]], plus interception markers like (..)(..)[id]).
  • The terminal segment is always page, and the result is /${segments.join('/')}.

vinext's createAppPageSourcePage builds /${[...routeSegments, "page"].join("/")} from raw filesystem segments (path.relative(appDir, routeDir) at app-route-graph.ts:2490, and path.relative(appDir, path.dirname(page)) for sibling intercepts at app-route-graph.ts:2409), which carry groups and brackets verbatim. This produces the same value Next.js does without needing a Flight router-state extractor.

Browser commit effect — correct. app-browser-entry.ts:1046 runs on [treeState.elements] and mirrors Next.js's useEffect(..., [state.tree]) assign-or-delete: writes the value when present, deletes the field when the metadata reads null (e.g. null-route boundary payloads that omit __sourcePage).

Wire compatibility — correct. __sourcePage is omitted for null/undefined, so older payloads round-trip cleanly. readSourcePageMetadata degrades malformed input to null (matching the surrounding readArtifactCompatibilityMetadata convention) rather than throwing — the prior reviewer's nit about throwing was based on a stale revision and is already resolved in the current code.

Validation (local):

  • vp test run on all 8 touched test files → 1327 passed (1188 + 139)
  • vp check on the changed source files → clean (format, lint, types)

One non-blocking observation below. LGTM.

rootLayoutTreePath: layoutEntries[0]?.treePath ?? null,
routeId,
sourcePage: options.route
? createAppPageSourcePage(options.sourcePageSegments ?? options.route.routeSegments)

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 (non-blocking): AppPageBoundaryRoute.routeSegments is typed optional (readonly string[] | undefined), so when options.route is truthy but its routeSegments is undefined, this calls createAppPageSourcePage(undefined) which returns "/page" and emits a __sourcePage field rather than omitting it.

In practice this is benign — real matched AppRoute values always populate routeSegments, and the route: null path already omits the field (covered by app-page-boundary-render.test.ts:474). But if you want strict parity with Next.js (which returns undefined when no source page can be derived), you could guard on the segments being present:

Suggested change
? createAppPageSourcePage(options.sourcePageSegments ?? options.route.routeSegments)
sourcePage:
options.route && (options.sourcePageSegments ?? options.route.routeSegments)
? createAppPageSourcePage(options.sourcePageSegments ?? options.route.routeSegments)
: null,

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Summary of my analysis of PR #1995:

Review outcome: Approve (LGTM)

Verified correct against Next.js. I fetched compute-changed-path.ts from Next.js canary and confirmed the PR's central invariant matches segmentToSourcePagePathname/extractSourcePageSegmentsFromFlightRouterState:

  • Route groups ((newroot)) are intentionally not stripped from the source page (only the visible-path normalizeSegments strips them).
  • Dynamic/catch-all segments keep their bracketed filesystem form ([id], [...slug], [[...slug]]).
  • The terminal segment is always page, yielding /${segments.join('/')}.

vinext's createAppPageSourcePage builds the value from raw filesystem segments (path.relative(appDir, routeDir)), which carry groups and brackets verbatim — producing the same value Next.js does without needing a Flight router-state extractor.

Browser commit effect correct. app-browser-entry.ts:1046 mirrors Next.js's useEffect(..., [state.tree]) assign-or-delete semantics: writes when present, deletes when the metadata reads null.

Wire compatibility correct. __sourcePage is omitted for null/undefined so old payloads round-trip cleanly, and readSourcePageMetadata degrades malformed input to null (the prior reviewer's "throwing" nit was based on a stale revision; the current code is fine).

Validation (local):

  • vp test run on all 8 touched test files → 1327 passed
  • vp check on changed source files → clean

One non-blocking inline observation: a boundary route with routeSegments === undefined would emit /page rather than omitting the field, since AppPageBoundaryRoute.routeSegments is typed optional. Benign in practice (real AppRoute values always populate segments), but I posted a suggestion for strict parity.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose active source page on window.next

Verified the implementation against the Next.js canary reference (compute-changed-path.ts and app-router.tsx) and ran the targeted suites locally.

Correctness vs Next.js — confirmed.

  • The central invariant is right: segmentToSourcePagePathname preserves route groups (group segments fall through to return segment and are not stripped — only the visible-path normalizeSegments strips them) and emits bracketed dynamic/catch-all forms ([id], [...slug], [[...slug]], interception markers like (..)(..)[id]). The terminal segment is always page, and the result is /${segments.join('/')}. vinext's createAppPageSourcePage builds /${[...routeSegments, "page"].join("/")} from raw filesystem segments (path.relative(appDir, routeDir) at app-route-graph.ts:2490, and path.dirname(page) for sibling intercepts at app-route-graph.ts:2409), which carry groups and brackets verbatim — same value, no Flight router-state extractor needed.
  • The root-page case matches: Next.js returns ['page']/page; createAppPageSourcePage([])/page ([] is truthy, so the boundary path correctly emits it rather than omitting).
  • The browser commit effect (app-browser-entry.ts:1046) runs on [treeState.elements], parallel to the adjacent setMountedSlotsHeader effect, mirroring Next.js's useEffect(..., [state.tree]) assign-or-delete: writes the value when present, deletes the field when the metadata reads null.

Wire compatibility — correct. __sourcePage is omitted for null/undefined (createMetadataEntries), so older payloads round-trip cleanly, and readSourcePageMetadata degrades malformed input (missing leading slash, non-string) to null rather than throwing — matching the surrounding readArtifactCompatibilityMetadata graceful-degrade convention. The prior reviewer's "throwing" nit is already resolved in this revision (validated by the degrades malformed optional source-page metadata to null test).

Validation (local):

  • vp test run tests/app-elements.test.ts tests/app-page-route-wiring.test.ts tests/shims.test.ts → 1188 passed

Tightly scoped, well-layered (segments flow correctly from route graph → matching → element builder → wire → browser commit), with good test coverage ported from the upstream contract. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review posted. Summary of my analysis of PR #1995:

Review outcome: Approve (LGTM)

Verified correct against Next.js canary. I fetched both compute-changed-path.ts and app-router.tsx and confirmed the PR's central invariant and effect semantics match:

  • Source-page derivation: segmentToSourcePagePathname preserves route groups ((newroot) is not stripped from the source page — only the visible-path normalizeSegments strips groups) and emits bracketed dynamic/catch-all forms ([id], [...slug], [[...slug]], (..)(..)[id]). vinext's createAppPageSourcePage builds the value from raw filesystem segments (path.relative(appDir, routeDir) at app-route-graph.ts:2490), which carry groups and brackets verbatim — producing the same string without a Flight router-state extractor.
  • Browser commit effect: app-browser-entry.ts:1046 runs on [treeState.elements], mirroring Next.js's useEffect(..., [state.tree]) assign-or-delete semantics (writes when present, deletes when null).
  • Wire compatibility: __sourcePage is omitted for null/undefined, so old payloads round-trip cleanly. readSourcePageMetadata degrades malformed input to null rather than throwing.

Notes on prior review threads: The earlier "throwing reader" nit is already resolved in the current code, and the routeSegments === undefined boundary observation is benign (the boundary path guards on truthiness and omits the field, and the root-page [] case correctly yields /page matching Next.js).

Validation: Ran the 3 targeted test files locally → 1188 passed.

The change is tightly scoped and well-layered. No blocking issues found.

github run

@james-elicx
james-elicx merged commit dabcb55 into cloudflare:main Jun 13, 2026
42 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants