Skip to content

feat(app-router): add artifact compatibility metadata#1062

Merged
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-compat-01-envelope
May 6, 2026
Merged

feat(app-router): add artifact compatibility metadata#1062
james-elicx merged 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/726-compat-01-envelope

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 5, 2026

What this changes

Implements #726-COMPAT-01 plus #726-COMPAT-02/03 from #726.

This PR adds the App Router artifact compatibility envelope, attaches concrete render-boundary metadata to outgoing record payloads, and defines a conservative compatibility evaluator for future cache/skip callers.

Why

Issue #726 requires every reusable or skippable artifact to carry compatibility proof before cache reuse or skip transport can depend on it. The rule for this slice is deliberately strict: no proof, no reuse. Unknown compatibility is represented as a render-fresh fallback, never as positive compatibility proof.

Approach

  • Add packages/vinext/src/server/artifact-compatibility.ts with schema constants, ArtifactCompatibilityEnvelope, constructor/parser helpers, a route/root graph fingerprint helper, and evaluateArtifactCompatibility().
  • Keep legacy/malformed wire parsing fail-closed: missing legacy metadata defaults to an unknown-proof envelope; malformed or future-schema metadata is rejected.
  • Attach compatibility metadata in renderAppPageLifecycle when the outgoing App Router payload is a record:
    • graph metadata from route pattern plus root boundary fingerprint
    • deployment metadata from process.env.__VINEXT_BUILD_ID
    • schema metadata from the envelope constants
    • rootBoundaryId from __rootLayout when available
    • renderEpoch: null until a real epoch owner exists
  • Preserve current semantics: no cache reuse, no skip transport, and no rolling-deploy protocol completeness in this PR.

Correctness oracle

Vinext internal invariant from #726: no reusable or skippable artifact should gain implicit compatibility proof from cache presence, artifact presence, or wire shape. Unknown graph/deployment/root/epoch compatibility must return a render-fresh fallback, and only fully known matching metadata can produce compatible.

A focused Next.js source/test search for artifact compatibility, compatibility envelope, renderEpoch, deploymentVersion, and graphVersion found no public Next.js behavior to port for this internal Vinext protocol. This PR does not claim public Next.js behavior parity for rolling deploys.

Validation

  • vp test run tests/app-elements.test.ts tests/app-page-render.test.ts
  • vp check packages/vinext/src/server/artifact-compatibility.ts packages/vinext/src/server/app-elements.ts packages/vinext/src/server/app-page-render.ts tests/app-elements.test.ts tests/app-page-render.test.ts
  • vp run vinext#build

Risks / follow-ups

  • The graphVersion helper is a narrow route/root fingerprint until the RouteManifest graphVersion from GRAPH-02/03 exists.
  • renderEpoch is carried but remains null, so current artifacts cannot become positive reuse proof solely from matching graph/deployment/root metadata.
  • #726-COMPAT-04/05 should add old-client/new-server coverage and hard-navigation loop prevention before any broader deployment fallback behavior depends on this envelope.

Refs #726

App Router payloads now carry a parsed compatibility envelope at the existing payload metadata boundary. Without this skeleton, future cache and skip work would have no typed place to record graph, deployment, schema, root-boundary, or render-epoch proof.

Add a small constructor/parser pair, attach a default unknown-proof envelope to outgoing record payloads, and reject malformed envelope metadata when reading payload metadata. Tests cover explicit envelopes, legacy missing envelopes, malformed envelopes, and non-mutating payload writes.
@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@1062

commit: d2ec7c2

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 5, 2026 07:08
Copilot AI review requested due to automatic review settings May 5, 2026 07:08
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: feat(app-router): add artifact compatibility envelope

Clean skeleton PR. The typed envelope, fail-closed parse boundary, and unknown-proof default are all well-structured. Tests pass (32/32). A few items to address:

Issues

  1. Redundant schema versions baked into every payloadappElementsSchemaVersion and rscPayloadSchemaVersion are hardcoded constants that always equal 1. They're redundant with schemaVersion (which already versions the envelope format). If the app-elements wire shape changes, you'd bump the envelope schemaVersion anyway. Carrying three version numbers on every outgoing RSC row adds wire overhead for no benefit. Consider dropping these two fields and relying on schemaVersion alone to version the envelope, or document why per-layer versioning is needed.

  2. parseArtifactCompatibilityEnvelope silently coerces future schema versions to null — If a newer server sends schemaVersion: 2, the parser returns null, and the caller throws. That's correct fail-closed behavior. But the error message says "Invalid __artifactCompatibility" which doesn't distinguish between corruption and a legitimate version mismatch. When #726-COMPAT-02 lands with actual cache-hit paths, you'll want to tell the difference. Worth a comment or a follow-up item now.

  3. buildOutgoingAppPayload always attaches the envelope even when no caller passes it — The single production callsite in app-page-render.ts:257 does not pass artifactCompatibility, so every payload gets a default all-nulls envelope. That's fine for the skeleton, but it means the wire format changes for every App Router response right now with no consumer. If this is intentional (to start populating the field so readers can depend on it), that's fine, just worth noting.

Nits

  1. Minor: the withArtifactCompatibilityEnvelope helper is private and only called in one place inside buildOutgoingAppPayload. The indirection is fine for symmetry with withLayoutFlags, but unlike withLayoutFlags (which is exported and tested independently), this one could be inlined. Not blocking.

  2. Test coverage is solid — the three cases (present envelope, missing/legacy, malformed) cover the parse boundary well. One gap: there's no test for a future/unknown schemaVersion (e.g., schemaVersion: 99). That's the most realistic failure mode in production and worth an explicit test case.

@@ -0,0 +1,71 @@
export const ARTIFACT_COMPATIBILITY_SCHEMA_VERSION = 1;
export const APP_ELEMENTS_SCHEMA_VERSION = 1;
export const RSC_PAYLOAD_SCHEMA_VERSION = 1;
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.

These two constants (APP_ELEMENTS_SCHEMA_VERSION, RSC_PAYLOAD_SCHEMA_VERSION) are always 1 and always match ARTIFACT_COMPATIBILITY_SCHEMA_VERSION. Every outgoing payload carries all three, but they're never checked independently — hasCurrentSchemaVersions requires all three to match their constants simultaneously.

What's the versioning scenario where appElementsSchemaVersion and rscPayloadSchemaVersion would diverge from the envelope's schemaVersion? If there isn't one yet, consider collapsing to a single version field to keep the wire format lean. If there is a planned use case (e.g., the RSC payload schema evolves independently of the elements schema), a short comment here explaining the intent would help future readers.

value: unknown,
): ArtifactCompatibilityEnvelope | null {
if (!isRecord(value)) return null;
if (!hasCurrentSchemaVersions(value)) 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.

When hasCurrentSchemaVersions returns false, this returns null, which the caller turns into an unconditional throw. That's correct fail-closed behavior, but it conflates two very different scenarios:

  1. Version mismatch (e.g., schemaVersion: 2 from a newer deployment) — expected during rolling deploys
  2. Corruption (e.g., schemaVersion: "banana") — a bug

Worth at least a comment here noting that once cache-hit or skip-transport paths depend on this, the caller should distinguish these cases (e.g., version-mismatch → treat as cache miss, corruption → log and reject).

return withLayoutFlags(input.element, input.layoutFlags);
return withArtifactCompatibilityEnvelope(
withLayoutFlags(input.element, input.layoutFlags),
input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(),
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 default means every App Router response now carries __artifactCompatibility on the wire even though the only production callsite (app-page-render.ts:257) never passes it. The envelope is all nulls (unknown proof), so it's semantically inert, but it does increase payload size.

Is the intent to always emit the field so that readers can unconditionally depend on its presence? If so, a brief comment here would clarify that for future readers. If not, consider making the attachment conditional (only when artifactCompatibility is explicitly provided).

},
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
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 coverage for the type-validation case (graphVersion: 123). Missing a test for the most realistic production failure mode: future schema version. A payload with schemaVersion: 99 (all other fields valid) should also be rejected. This would exercise the hasCurrentSchemaVersions guard path specifically:

Suggested change
});
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
it("rejects artifact compatibility with an unrecognized schema version", () => {
expect(() =>
readAppElementsMetadata({
...normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
}),
[APP_ARTIFACT_COMPATIBILITY_KEY]: {
schemaVersion: 99,
graphVersion: null,
deploymentVersion: null,
appElementsSchemaVersion: APP_ELEMENTS_SCHEMA_VERSION,
rscPayloadSchemaVersion: RSC_PAYLOAD_SCHEMA_VERSION,
rootBoundaryId: null,
renderEpoch: null,
},
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

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

Issues raised:

  1. Redundant schema versionsappElementsSchemaVersion and rscPayloadSchemaVersion are always 1 alongside schemaVersion. All three are checked simultaneously in hasCurrentSchemaVersions. Unless there's a planned scenario where these diverge, collapsing to a single version field would reduce wire overhead.

  2. Version mismatch vs corruptionparseArtifactCompatibilityEnvelope returns null for both a future schemaVersion: 2 (legitimate rolling deploy scenario) and actual corruption (schemaVersion: "banana"). The caller throws the same error for both. Worth distinguishing these before cache-hit paths depend on this.

  3. Always-on envelope emission — The only production callsite (app-page-render.ts:257) never passes artifactCompatibility, so every response now carries an all-nulls envelope with no consumer. Fine for a skeleton, but should be documented as intentional.

  4. Suggested test addition — Missing a test case for unrecognized future schema version (schemaVersion: 99), which is the most realistic production failure mode. Included as a code suggestion on the PR.

github run

The review correctly identified that the envelope parser had malformed-field coverage but no direct test for the version guard path. Add the future-schema-version case and document why the Wave01 skeleton emits separate schema versions and an unknown-proof envelope without yet splitting mismatch from corruption behavior.
@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: feat(app-router): add artifact compatibility envelope

The skeleton is well-structured overall — typed envelope, fail-closed parse boundary, unknown-proof default, and clean test coverage. But there are a few issues that should be addressed before merging, mostly around unnecessary wire overhead and silent consumer breakage.

Must-fix

  1. Every consumer of readAppElementsMetadata now receives artifactCompatibility but none of them use it. app-browser-entry.ts (lines 427, 1135), app-browser-state.ts (line 215), and app-ssr-entry.ts (line 221) all destructure or read specific fields from the returned metadata — none read artifactCompatibility. This is fine for a skeleton, but the readAppElementsMetadata parse path now throws on malformed envelopes (line 244), which means a corrupt or future-version __artifactCompatibility value on the wire will crash the browser entry, SSR entry, and browser state — all for a field nobody consumes yet. The fail-closed behavior is correct in principle, but crashing the entire render pipeline for an unused field is aggressive. Consider either (a) making the parse non-throwing when no consumer exists (log + default to unknown proof), or (b) documenting why a hard crash on an unused field is the intentional tradeoff.

  2. parseArtifactCompatibilityEnvelope reconstructs the envelope from constants instead of preserving parsed values. Lines 70-76 overwrite schemaVersion, appElementsSchemaVersion, and rscPayloadSchemaVersion with the local constants after validating them. This is technically correct today (the version check on line 63 already ensures equality), but it means the function's return type can never carry a different schema version — making it impossible to extend for forward-compat without changing the function signature. If this is intentional ("we always normalize to current"), add a comment. If not, return value.schemaVersion etc. directly.

Should-fix

  1. Three version fields that always have the same value. schemaVersion, appElementsSchemaVersion, and rscPayloadSchemaVersion are all 1, checked together in hasCurrentSchemaVersions, and constructed together in createArtifactCompatibilityEnvelope. The PR description and comment on lines 3-5 say they could diverge in the future, but there's no concrete scenario described. Every outgoing RSC row now carries three extra fields for this speculative use case. Recommend collapsing to a single schemaVersion until there's an actual need for per-layer versioning — it's easy to split later when the semantics are clear.

  2. Missing test: non-object __artifactCompatibility value. The tests cover graphVersion: 123 (wrong field type) and schemaVersion: 99 (future version), but don't test a non-object value like __artifactCompatibility: "garbage" or __artifactCompatibility: 42. The isRecord guard on line 59 handles this, but it should have an explicit test.

Nit

  1. withArtifactCompatibilityEnvelope is private, called once, and follows the same pattern as the exported withLayoutFlags. The symmetry is fine, but unlike withLayoutFlags it has no independent tests and no external consumers. If it stays private and single-use, consider inlining it into buildOutgoingAppPayload to reduce indirection.

? createArtifactCompatibilityEnvelope()
: parseArtifactCompatibilityEnvelope(artifactCompatibilityValue);
if (!artifactCompatibility) {
throw new Error("[vinext] Invalid __artifactCompatibility in App Router payload");
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 throw is reachable from three production callsites (app-browser-entry.ts:427, app-browser-entry.ts:1135, app-ssr-entry.ts:221) — none of which read artifactCompatibility from the returned metadata. A hard crash for an unused field is a surprising failure mode.

If the intent is defense-in-depth ("reject payloads that are structurally invalid even if we don't consume the field yet"), that's a valid choice but should be documented with a comment here explaining why. Otherwise, consider degrading gracefully:

Suggested change
throw new Error("[vinext] Invalid __artifactCompatibility in App Router payload");
if (!artifactCompatibility) {
// TODO(#726-COMPAT-02): throw once consumers depend on this field.
// For now, degrade to unknown proof so a malformed envelope doesn't
// crash the render pipeline for a field nobody reads yet.
// throw new Error("[vinext] Invalid __artifactCompatibility in App Router payload");
}

(Or keep the throw but add a comment explaining the intentional tradeoff.)

// but future rolling deploy work can bump the envelope shape independently
// from the flat AppElements record or the serialized RSC payload.
export const APP_ELEMENTS_SCHEMA_VERSION = 1;
export const RSC_PAYLOAD_SCHEMA_VERSION = 1;
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 comment on lines 3-5 says these "can" diverge, but there's no concrete scenario where they would. Today hasCurrentSchemaVersions checks all three together, createArtifactCompatibilityEnvelope sets all three to 1, and the type pins them to typeof their respective constants.

Carrying two extra version fields on every outgoing RSC row for a speculative future use case adds wire overhead now. If/when a real scenario emerges (e.g., the RSC payload format evolves independently of the elements record), it's easy to add the field then.

Recommend collapsing to a single schemaVersion and re-introducing per-layer versions when there's a concrete need. At minimum, if you want to keep them, document a specific scenario in the comment rather than the generic "future rolling deploy work can bump...".

if (!isStringOrNull(value.renderEpoch)) return null;

return {
schemaVersion: ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
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 reconstructs the return value from local constants (ARTIFACT_COMPATIBILITY_SCHEMA_VERSION, APP_ELEMENTS_SCHEMA_VERSION, RSC_PAYLOAD_SCHEMA_VERSION) instead of using the validated values from the input (value.schemaVersion, etc.).

Since hasCurrentSchemaVersions on line 63 already verified exact equality, this is semantically identical today. But it means the function's return type is structurally locked to the current version — a future reader might expect that parsing schemaVersion: 2 could return an envelope with schemaVersion: 2, but it can't.

If this is intentional normalization ("we always stamp the current constants regardless of input"), add a brief comment. If not:

Suggested change
schemaVersion: ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
return {
schemaVersion: value.schemaVersion as typeof ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
graphVersion: value.graphVersion,
deploymentVersion: value.deploymentVersion,
appElementsSchemaVersion: value.appElementsSchemaVersion as typeof APP_ELEMENTS_SCHEMA_VERSION,
rscPayloadSchemaVersion: value.rscPayloadSchemaVersion as typeof RSC_PAYLOAD_SCHEMA_VERSION,
rootBoundaryId: value.rootBoundaryId,
renderEpoch: value.renderEpoch,
};

},
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
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 — this test was missing from the initial commit and was added per the prior review. Consider also adding a test for non-object values to exercise the isRecord guard:

Suggested change
});
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
it("rejects non-object artifact compatibility values", () => {
expect(() =>
readAppElementsMetadata({
...normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
}),
[APP_ARTIFACT_COMPATIBILITY_KEY]: "not-an-object",
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 5, 2026

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

Must-fix:

  1. Throwing on an unused field crashes three production callsitesapp-browser-entry.ts, app-browser-state.ts, and app-ssr-entry.ts all call readAppElementsMetadata but none read artifactCompatibility. A malformed or future-version envelope on the wire will crash the entire render pipeline for a field nobody consumes. Either degrade gracefully or document the intentional tradeoff.

  2. Parser reconstructs from constants instead of preserving validated inputparseArtifactCompatibilityEnvelope overwrites version fields with local constants after validating them, structurally locking the return type to the current version and preventing forward-compat extension without a signature change.

Should-fix:

  1. Three redundant version fieldsschemaVersion, appElementsSchemaVersion, and rscPayloadSchemaVersion are always 1, always checked together, always constructed together. No concrete divergence scenario is described. Recommend collapsing to a single schemaVersion.

  2. Missing test for non-object values — The isRecord guard handles "garbage" and 42, but there's no explicit test exercising that path.

Nit:

  1. withArtifactCompatibilityEnvelope is private and single-use — consider inlining.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Bonk can't see the big picture so I'll just do compat02 as well

@NathanDrake2406 NathanDrake2406 changed the title feat(app-router): add artifact compatibility envelope feat(app-router): add artifact compatibility metadata May 6, 2026
App Router payloads previously carried only the COMPAT-01 unknown envelope unless a caller manually supplied metadata. That left COMPAT-02/03 without concrete graph, deployment, root-boundary, and fallback behavior in the PR.

The render lifecycle now attaches compatibility metadata for record payloads from route/root/deployment facts, while renderEpoch remains unknown until an epoch owner exists. The compatibility evaluator returns explicit compatible, unknown, or incompatible decisions so unknown proof always falls back to render fresh instead of becoming reuse proof.

Tests cover render-boundary metadata attachment and unknown graph, deployment, root-boundary, and render-epoch decisions.
…1-envelope

# Conflicts:
#	packages/vinext/src/server/app-elements.ts
#	packages/vinext/src/server/app-page-render.ts
#	tests/app-elements.test.ts
@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: feat(app-router): add artifact compatibility metadata

The envelope design, fail-closed parser, and unknown-proof default are well-structured. The evaluator logic is clean and the test coverage is solid. Two issues need addressing before this merges.

Must-fix

  1. Hard throw on malformed envelope crashes consumers that never read the field. readAppElementsMetadata now throws on line 247 of app-elements-wire.ts when parseArtifactCompatibilityEnvelope returns null. Every consumer of readMetadata hits this path — app-browser-entry.ts:457 (initial mount), app-browser-entry.ts:1165 (client navigation), app-ssr-entry.ts:201 (SSR render), app-browser-state.ts:358 (pending nav commit). None of these read artifactCompatibility from the returned metadata. A future schema version (schemaVersion: 2) or a corrupted field on the wire will crash the entire render pipeline for a value nobody consumes. The layoutFlags field sets the right precedent here — parseLayoutFlags degrades gracefully to {} on invalid input. The artifact compatibility parse should do the same until consumers exist.

  2. evaluateArtifactCompatibility is dead code with no callsite. The function is exported, tested, but never called from any production code path. It's fine to land it alongside the envelope as scaffolding, but it should have a brief comment noting it's pre-positioned for #726-COMPAT-04/05 consumers — otherwise it looks like an oversight.

Should-fix

  1. Three version fields that always have the same value. schemaVersion, appElementsSchemaVersion, and rscPayloadSchemaVersion are all 1, always checked together in hasCurrentSchemaVersions, and always constructed together. The comment on lines 5-7 says they "can" diverge in the future, but no concrete scenario is described. Recommend either (a) collapsing to a single schemaVersion and splitting later when the semantics are clear, or (b) adding a concrete example of when they'd diverge to justify the wire overhead.

  2. Parser reconstructs from constants instead of preserving validated input. Lines 114-122 of artifact-compatibility.ts overwrite schemaVersion, appElementsSchemaVersion, and rscPayloadSchemaVersion with local constants after the hasCurrentSchemaVersions check already verified equality. This structurally locks the return type to the current version and prevents forward-compat extension without a function signature change. If this is intentional normalization, add a comment. If not, return value.schemaVersion etc. directly.

Nit

  1. Missing test for non-object __artifactCompatibility values. The isRecord guard on line 104 handles non-object inputs, but there's no test exercising that path (e.g., __artifactCompatibility: "garbage" or 42).

? createArtifactCompatibilityEnvelope()
: parseArtifactCompatibilityEnvelope(artifactCompatibilityValue);
if (!artifactCompatibility) {
throw new Error("[vinext] Invalid __artifactCompatibility in App Router payload");
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 throw is reachable from four production callsites (app-browser-entry.ts:457, app-browser-entry.ts:1165, app-ssr-entry.ts:201, app-browser-state.ts:358) — none of which read artifactCompatibility from the returned metadata.

A malformed or future-version __artifactCompatibility on the wire will crash the entire render pipeline for a field nobody consumes yet. Compare with layoutFlags directly above — parseLayoutFlags degrades gracefully to {} when the value is invalid, which is the right pattern for a field with no active consumers.

Suggested change
throw new Error("[vinext] Invalid __artifactCompatibility in App Router payload");
if (!artifactCompatibility) {
// TODO(#726-COMPAT-02): throw once consumers depend on this field.
// For now, degrade to unknown proof so a malformed envelope doesn't
// crash the render pipeline for a field nobody reads yet.
artifactCompatibility = createArtifactCompatibilityEnvelope();
}

return null;
}

export function evaluateArtifactCompatibility(
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 function is exported and well-tested, but has zero callsites in production code. Worth adding a brief comment noting it's pre-positioned for #726-COMPAT-04/05 so it doesn't look like dead code that was accidentally left behind.

Suggested change
export function evaluateArtifactCompatibility(
/** Pre-positioned for #726-COMPAT-04/05 cache/skip callers. No production callsite yet. */
export function evaluateArtifactCompatibility(

// These versions describe separate protocol layers. They start in lockstep,
// but future rolling deploy work can bump the envelope shape independently
// from the flat AppElements record or the serialized RSC payload.
export const APP_ELEMENTS_SCHEMA_VERSION = 1;
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.

Both APP_ELEMENTS_SCHEMA_VERSION and RSC_PAYLOAD_SCHEMA_VERSION are always 1, always checked together with schemaVersion in hasCurrentSchemaVersions, and always set together in createArtifactCompatibilityEnvelope. Every outgoing RSC row now carries three version fields with identical values.

The comment says they "can" diverge, but no concrete scenario is described. If the app-elements wire shape changes, you'd bump the envelope schemaVersion anyway. Recommend either collapsing to a single schemaVersion (easy to split later when the need is concrete), or adding a specific scenario where they'd diverge to justify the overhead.

Comment on lines +114 to +115
return {
schemaVersion: ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
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.

These lines overwrite the version fields with local constants after hasCurrentSchemaVersions already verified equality. This means the function can structurally never return a different schema version — a forward-compat concern if you later want to parse-and-preserve a schemaVersion: 2 envelope.

If this is intentional normalization ("always stamp the current constants regardless of input"), add a brief comment. Otherwise, return value.schemaVersion etc. directly:

Suggested change
return {
schemaVersion: ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
schemaVersion: value.schemaVersion as typeof ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
graphVersion: value.graphVersion,
deploymentVersion: value.deploymentVersion,
appElementsSchemaVersion: value.appElementsSchemaVersion as typeof APP_ELEMENTS_SCHEMA_VERSION,
rscPayloadSchemaVersion: value.rscPayloadSchemaVersion as typeof RSC_PAYLOAD_SCHEMA_VERSION,

},
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
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 coverage for future schema version and wrong field type. Missing a test for a non-object value to exercise the isRecord guard at line 104 of artifact-compatibility.ts:

Suggested change
});
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});
it("rejects non-object artifact compatibility values", () => {
expect(() =>
readAppElementsMetadata({
...normalizeAppElements({
[APP_ROOT_LAYOUT_KEY]: "/",
[APP_ROUTE_KEY]: "route:/dashboard",
}),
[APP_ARTIFACT_COMPATIBILITY_KEY]: "not-an-object",
}),
).toThrow("[vinext] Invalid __artifactCompatibility in App Router payload");
});

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

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

Must-fix:

  1. Hard throw on malformed envelope crashes four production callsites that never read the field. readAppElementsMetadata throws when parseArtifactCompatibilityEnvelope returns null, but app-browser-entry.ts, app-ssr-entry.ts, and app-browser-state.ts all call readMetadata without ever reading artifactCompatibility. A future schema version or corrupted wire value will crash the entire render pipeline for an unused field. The layoutFlags parser sets the right precedent — it degrades gracefully to {} on invalid input. Suggested the same pattern with a TODO to throw once consumers exist.

  2. evaluateArtifactCompatibility has no production callsite. Exported, tested, but dead code. Fine as scaffolding, but needs a comment noting it's pre-positioned for #726-COMPAT-04/05.

Should-fix:

  1. Three redundant version fieldsschemaVersion, appElementsSchemaVersion, rscPayloadSchemaVersion are always 1, always checked together, always constructed together. No concrete divergence scenario is described. Recommend collapsing to a single schemaVersion or documenting a specific scenario.

  2. Parser reconstructs from constants instead of preserving validated input — structurally locks the return type to the current version, preventing forward-compat extension. Suggested either a comment explaining the intentional normalization or returning value.schemaVersion directly.

Nit:

  1. Missing test for non-object values — the isRecord guard handles "garbage" and 42, but has no explicit test.

github run

App Router metadata parsing crashed when __artifactCompatibility was malformed or from a future schema, even though current render and navigation callers do not consume that field yet.

The parser treated compatibility scaffolding like required route metadata. That made an unused proof capable of failing initial mount, SSR, and client navigation paths.

Default invalid compatibility metadata to the unknown proof for Wave01, document the planned hard-fail point for cloudflare#726-COMPAT-04/05, and cover malformed, future-version, and non-object payloads.
@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: feat(app-router): add artifact compatibility metadata

The latest revision addresses the critical must-fix from prior reviews — readArtifactCompatibilityMetadata now degrades gracefully to an unknown-proof envelope instead of throwing, matching the parseLayoutFlags precedent. The fail-closed parser, typed envelope, evaluator logic, and test coverage are all clean.

Two remaining items from prior reviews that I agree with in principle but are fine to defer:

  1. Three version fields that are always 1schemaVersion, appElementsSchemaVersion, rscPayloadSchemaVersion. The comment on lines 5-7 explains the intent (independent layer versioning for rolling deploys), which is a reasonable forward-looking choice. If this ends up never diverging, it's easy to collapse later. Not blocking.

  2. evaluateArtifactCompatibility has no production callsite — Already addressed with the comment at line 157 noting it's pre-positioned for #726-COMPAT-04/05.

One small observation and one nit below. Overall this is a well-structured skeleton that does what it says — adds the envelope, emits it on outgoing payloads, and keeps the render pipeline safe from parse failures in a field nobody reads yet.

...input.element,
[APP_LAYOUT_FLAGS_KEY]: input.layoutFlags,
[APP_ARTIFACT_COMPATIBILITY_KEY]:
input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(),
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.

When artifactCompatibility is not passed, the default envelope is created twice — once here via createArtifactCompatibilityEnvelope(), and once on the read side in readArtifactCompatibilityMetadata when the value round-trips through the parser successfully. This is fine (the envelope is cheap to construct), but worth noting that the write side always emits the envelope even for non-record payloads where createAppPageArtifactCompatibility returns undefined — because buildOutgoingAppPayload only reaches this line for record elements (the early return on line 199 handles non-records). So the fallback here only fires when the production callsite in app-page-render.ts doesn't pass artifactCompatibility, which currently only happens if isAppElementsRecord returns false for the element (which then means this line is unreachable since we already returned on line 200). In practice, the ?? fallback is dead code for the current callsite — it would only matter for future callers that pass a record element without artifactCompatibility. Not blocking, just a note for context.

const outgoingElement = AppElementsWire.encodeOutgoingPayload({
element: options.element,
layoutFlags,
...(artifactCompatibility ? { artifactCompatibility } : {}),
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 conditional spread ...(artifactCompatibility ? { artifactCompatibility } : {}) is equivalent to just artifactCompatibility since buildOutgoingAppPayload already has artifactCompatibility?: (optional) in its input type, and undefined properties are ignored by the spread. This could be simplified to:

Suggested change
...(artifactCompatibility ? { artifactCompatibility } : {}),
artifactCompatibility,

The conditional spread pattern suggests the field might be harmful if passed as undefined, but the implementation in buildOutgoingAppPayload handles the missing case with ?? createArtifactCompatibilityEnvelope() anyway. Minor style nit, not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review posted. Here's a summary:

Approved. The latest revision addresses the critical must-fix from prior rounds — malformed/future-version envelopes now degrade gracefully to unknown-proof instead of crashing the render pipeline. The typed envelope, fail-closed parser, evaluator logic, and test coverage (65 tests passing) are all clean.

Feedback posted:

  1. Observation on the default envelope fallback — The ?? createArtifactCompatibilityEnvelope() fallback in buildOutgoingAppPayload is technically unreachable from the current production callsite in app-page-render.ts, since whenever the element is a record (the only path that reaches line 206), createAppPageArtifactCompatibility always returns a concrete envelope. Just a note for context.

  2. Nit: conditional spread is unnecessary...(artifactCompatibility ? { artifactCompatibility } : {}) in app-page-render.ts:293 can be simplified to just artifactCompatibility, since the property is optional in the input type and undefined values are ignored by object spreads.

Deferred items acknowledged as reasonable: the three version fields (collapsible later if they never diverge) and evaluateArtifactCompatibility being dead code (already documented as pre-positioned for COMPAT-04/05).

github run

@james-elicx james-elicx merged commit 15c8079 into cloudflare:main May 6, 2026
26 checks passed
james-elicx added a commit to piffie/vinext that referenced this pull request May 6, 2026
…ound-trip expectation

readAppElementsMetadata always returns an artifactCompatibility envelope after cloudflare#1062, but the round-trip test added in cloudflare#1088 still asserted the pre-cloudflare#1062 shape, breaking once both landed on main.
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