Skip to content

refactor(app-router): introduce AppElementsWire boundary#1059

Merged
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-wire-01-app-elements-wire
May 5, 2026
Merged

refactor(app-router): introduce AppElementsWire boundary#1059
james-elicx merged 2 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-wire-01-app-elements-wire

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

Introduces #726-WIRE-01 by making AppElements wire transport pass through an explicit AppElementsWire codec boundary.

Production App Router callers now use AppElementsWire for route ids, page ids, cache keys, metadata entries, outgoing payload layout flags, wire decode, and metadata reads. The existing app-elements module remains as the compatibility barrel for current helper imports, but current production encode/decode/write paths no longer assemble raw AppElements wire metadata directly.

Why

Issue #726 calls out the flat keyed AppElements payload as a transport bridge, not the router brain. Before this PR, route identity, cache identity, metadata writes, unmatched-slot transport, and payload parsing were spread across call sites as individual helper calls and raw keys. That makes later ownership work harder because there is no single place to reason about the RSC/HTML payload boundary.

Correctness oracle: Vinext internal invariant. This PR preserves current observable App Router behavior while introducing an enforceable wire owner for the next #726 waves.

Approach

Add server/app-elements-wire.ts as the codec owner for:

  • wire constants and the unmatched-slot transport sentinel
  • AppElements and AppWireElements decode
  • route, page, and cache key encoding
  • canonical metadata entry creation
  • outgoing payload shaping with layout flags
  • metadata parsing and payload-record detection

Then route the current production call sites through AppElementsWire:

  • RSC route wiring, fallback boundaries, no-default-export payloads, and generated RSC action not-found payloads
  • SSR and browser RSC decode plus metadata reads
  • visited-response and prefetch cache key construction
  • next/link and next/navigation prefetch dedupe keys

This intentionally does not promote topology, slot ownership, lifecycle authority, or cache semantics. Those remain later #726 waves.

Validation

  • vp test run tests/app-elements.test.ts tests/slot.test.ts tests/app-browser-entry.test.ts tests/app-page-element-builder.test.ts tests/app-page-render.test.ts tests/app-page-route-wiring.test.ts tests/app-fallback-renderer.test.ts tests/app-router.test.ts tests/entry-templates.test.ts tests/link.test.ts tests/prefetch-cache.test.ts
  • vp check packages/vinext/src/server/app-elements-wire.ts packages/vinext/src/server/app-elements.ts packages/vinext/src/server/app-page-route-wiring.tsx packages/vinext/src/server/app-page-boundary-render.ts packages/vinext/src/server/app-page-element-builder.ts packages/vinext/src/server/app-page-render.ts packages/vinext/src/server/app-ssr-entry.ts packages/vinext/src/server/app-browser-state.ts packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/shims/navigation.ts packages/vinext/src/shims/link.tsx packages/vinext/src/entries/app-rsc-entry.ts tests/app-elements.test.ts
  • vp run vinext#build

The focused test matrix passed 530 tests. The build completed with the repo's existing virtual-module unresolved-import warnings.

Risks / follow-ups

Open PR overlap risk exists around App Router generated entry and browser navigation files, especially #1057, #922, #698, #647, #488, #404 and older dirty PRs. This branch is based on current upstream/main and currently publishes cleanly from there.

WIRE-02 should remove or lint against raw wire-key construction outside AppElementsWire. This PR deliberately leaves legacy helper exports where covered by existing compatibility tests so the ownership migration stays reviewable.

App Router payload ownership currently leaks through raw helper calls for route ids, cache keys, metadata writes, and decode paths. That makes the flat AppElements transport act like router semantics instead of a narrow wire boundary, which violates the cloudflare#726-WIRE-01 ownership target.

Introduce AppElementsWire as the single codec facade for AppElements wire encoding, decoding, metadata creation, and outgoing payload shaping. Route the current production encode/decode/write call sites through that facade while keeping the legacy helper names re-exported only where existing tests cover compatibility.
Copilot AI review requested due to automatic review settings May 5, 2026 06:18
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 5, 2026

Open in StackBlitz

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

commit: 4ebb212

Copy link
Copy Markdown
Contributor

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@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: refactor(app-router): introduce AppElementsWire boundary

Clean extraction. The move from scattered key-constant + helper-function imports to a single AppElementsWire codec object is a net readability win — call sites like AppElementsWire.encodeRouteId(...) are self-documenting compared to the previous createAppPayloadRouteId(...) + raw APP_ROUTE_KEY assignments. The barrel re-export strategy in app-elements.ts is the right call for a non-breaking migration.

A few things worth addressing before merge:

  1. AppElementsWire.keys is dead code in this PR — it's defined and typed but has zero consumers. If it's speculative API surface for WIRE-02+, that's fine, but worth a comment saying so. Otherwise it should be dropped to keep the public surface minimal.

  2. isLayoutFlagsRecord accepts {} as valid LayoutFlags — this is correct behavior (empty flags = no layouts probed), but it also means any empty object passes the predicate. That's inherited from before this PR, just noting it since the function moved to a new codec boundary where the contract is being tightened.

  3. Minor: the barrel in app-elements.ts re-exports createAppPayloadRouteId and createAppPayloadCacheKey as named exports for backward compat, but createAppPayloadPageId was dropped. That's intentional (no external callers), but worth a brief inline comment in the barrel to document the deliberate omission, since it's the only one of the three create* helpers that got fully privatized.

Otherwise the migration is mechanically correct — all 16 internal consumers import through the barrel, the generated entry template correctly uses __AppElementsWire, and the new tests cover the codec round-trip well.

readonly layoutFlags: typeof APP_LAYOUT_FLAGS_KEY;
readonly rootLayout: typeof APP_ROOT_LAYOUT_KEY;
readonly route: typeof APP_ROUTE_KEY;
};
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.

keys is typed and populated but has zero consumers in this PR or anywhere else in the codebase. Is this speculative surface for WIRE-02? If so, a brief comment would help. If not, consider dropping it — smaller public surface = fewer things to maintain.

}

function isLayoutFlagsRecord(value: unknown): value is LayoutFlags {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
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: !value rejects 0, "", false (all non-objects anyway) but also null — which is already handled by typeof value !== "object" being falsy for null. The !value guard is technically redundant for the null case since the typeof check would need to come first to exclude null. As written, the short-circuit order is fine because !null is true and bails before the typeof check, so it works correctly — but it reads like the null guard is the typeof check rather than !value.

No action needed, just noting the inherited subtlety for when this boundary gets revisited.

type AppOutgoingElements,
type AppWireElements,
type LayoutFlags,
} from "./app-elements-wire.js";
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 barrel re-exports createAppPayloadRouteId and createAppPayloadCacheKey for backward compat, but createAppPayloadPageId was intentionally dropped (no external callers). Consider adding a brief comment here documenting that omission, so future readers don't wonder whether it was accidental:

Suggested change
} from "./app-elements-wire.js";
} from "./app-elements-wire.js";
// NOTE: createAppPayloadPageId is intentionally not re-exported — it is only
// used internally through AppElementsWire.encodePageId.

rootLayoutTreePath,
routeId,
}),
);
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.

Using Object.assign to merge metadata entries into a mutable elements record works, but it's a different pattern from the other call sites (boundary render, element builder, RSC entry) which all use object spread (...AppElementsWire.createMetadataEntries(...)).

This is fine — elements is already mutable here — but the inconsistency stands out. Not blocking, just flagging for awareness.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

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

Overall: Clean extraction — the AppElementsWire codec object is a readability improvement over the previous scattered imports pattern, and the barrel re-export strategy in app-elements.ts makes this a non-breaking migration.

Key points raised:

  1. AppElementsWire.keys is dead code — defined and typed but has zero consumers anywhere. Needs either a comment explaining it's for WIRE-02+ or removal.

  2. Barrel re-export gapcreateAppPayloadPageId was intentionally privatized (unlike createAppPayloadRouteId and createAppPayloadCacheKey which remain re-exported for compat). Suggested adding a comment documenting the deliberate omission.

  3. Object.assign vs spread inconsistencyapp-page-route-wiring.tsx uses Object.assign to merge metadata entries while all other call sites use object spread. Not blocking, but worth awareness.

  4. Minor note on inherited isLayoutFlagsRecord subtlety with the !value guard.

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: refactor(app-router): introduce AppElementsWire boundary

This is a well-executed extraction. The codec object pattern (AppElementsWire.encodeRouteId(...), AppElementsWire.decode(...)) is a clear improvement over the previous scattered imports of bare key constants and helper functions. The migration is mechanically correct — all 16 internal consumers import through the barrel, no direct app-elements-wire.js imports leak outside the barrel, and the generated entry template correctly uses the __AppElementsWire alias.

Things I verified:

  • No behavioral change in isLayoutFlagsRecord: The guard changed from !value || typeof value !== "object" to typeof value !== "object" || value === null. These are equivalent — !value rejects null, undefined, 0, "", false, NaN, and typeof null === "object" means the explicit null check is needed after the typeof gate. The new version is actually clearer.
  • No stale references: normalizeAppElementsPromise was fully renamed to decodeAppElementsPromise (zero remaining refs). createAppPayloadPageId is correctly privatized with zero external callers.
  • Barrel completeness: All production-facing symbols are re-exported from app-elements.ts. No file outside app-elements.ts imports directly from app-elements-wire.ts.
  • No raw key string usage in production code: "__route", "__interceptionContext", etc. only appear as constant definitions inside app-elements-wire.ts.
  • Tests pass: All 31 tests in app-elements.test.ts pass, including the 3 new AppElementsWire codec round-trip tests.

Two small notes below — neither is blocking.

readonly layoutFlags: typeof APP_LAYOUT_FLAGS_KEY;
readonly rootLayout: typeof APP_ROOT_LAYOUT_KEY;
readonly route: typeof APP_ROUTE_KEY;
};
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 previous review flagged keys as dead code — I agree it has zero consumers today, but the comment on line 229-230 explains the intent well enough. No action needed; just noting I looked at this and the comment is sufficient.

One thought for WIRE-02: if the plan is for external consumers to use AppElementsWire.keys.route instead of importing APP_ROUTE_KEY directly, it'd be worth adding a test that asserts the key values match the raw constants. That way if someone renames a constant without updating keys, the test catches it. Not needed in this PR since keys is unused.

}

function isLayoutFlagsRecord(value: unknown): value is LayoutFlags {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
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 guard refactor from !value || typeof value !== "object" to typeof value !== "object" || value === null || Array.isArray(value) is a clarity improvement. The old !value was doing double duty (rejecting falsy non-objects AND null), which read oddly since typeof null === "object". The explicit null check here is better. Just noting this was intentional, not accidental.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

Review submitted and approved. Here's the summary:

Approved — clean extraction with no behavioral changes. The AppElementsWire codec object consolidates scattered key-constant and helper-function imports into a single boundary, and the barrel re-export strategy in app-elements.ts keeps this non-breaking.

Key verifications:

  • All 16 internal consumers route through the barrel (no direct app-elements-wire.js imports leak)
  • No raw wire key strings ("__route", etc.) appear in production code outside the wire module
  • The isLayoutFlagsRecord guard change is equivalent but clearer
  • normalizeAppElementsPromisedecodeAppElementsPromise rename is complete (zero stale refs)
  • createAppPayloadPageId correctly privatized
  • All 31 tests pass

github run

routePath: string,
interceptionContext: string | null,
): string {
return appendInterceptionContext(`route:${routePath}`, interceptionContext);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wasn't there a util in one of hte other prs for the route:${...} etc. stuff?

@james-elicx james-elicx merged commit e216dab into cloudflare:main May 5, 2026
28 checks passed
@NathanDrake2406 NathanDrake2406 deleted the nathan/726-wire-01-app-elements-wire branch May 6, 2026 04:30
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