Skip to content

fix(app-router): classify static metadata after rendering - #2330

Open
james-elicx wants to merge 12 commits into
mainfrom
codex/fix-metadata-streaming-static-28143992598
Open

james-elicx wants to merge 12 commits into
mainfrom
codex/fix-metadata-streaming-static-28143992598

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • defer static metadata head/body placement until the rendered Flight subtree completes
  • include nested page, parallel-slot, and active default-slot dynamic usage
  • preserve stream backpressure and clean up rendering on cancellation/error
  • apply pending static metadata during client navigation

Validation

  • 183 focused unit/integration tests
  • 2 production metadata navigation browser tests with one worker
  • exact Next.js v16.2.6 metadata static-generation suite: 7/7
  • focused check and package builds

Excludes cacheComponents/PPR/resume work.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: b3e041a

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b3e041a against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 3 regressed · 3 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 120.2 KB 🔴 +2.6%
Client entry size (gzip) vinext 112.5 KB 115.6 KB 🔴 +2.7%
Dev server cold start vinext 2.57 s 2.54 s ⚫ -1.3%
Production build time vinext 3.07 s 3.09 s ⚫ +0.7%
RSC entry closure size (gzip) vinext 93.0 KB 93.7 KB ⚫ +0.7%
Server bundle size (gzip) vinext 156.9 KB 161.0 KB 🔴 +2.6%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@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: classify static metadata after rendering

Reviewed the metadata placement deferral, the new trackStreamCompletion/lifecycle plumbing, the head-if-static wire path, and the SSR/browser placement. The core approach is sound and well-tested (ISR head, body-dynamic, parallel/default slots, navigation). A few concerns, one of which I think is a real regression.

1. Request context is cleared too early on the late special-error path (likely regression)

packages/vinext/src/server/app-page-render.ts ~L1020:

if (specialError) {
  void htmlStream.cancel().catch(() => {});
  finalizeRenderLifecycle(specialError); // <- calls options.clearRequestContext()
  return options.renderPageSpecialError(specialError);
}

finalizeRenderLifecycle() calls options.clearRequestContext() (which nulls the headers + navigation contexts in app-request-context.ts). It now runs before renderPageSpecialError(specialError).

But renderPageSpecialError → buildAppPageSpecialErrorResponse (app-page-execution.ts) assumes the request context is still live: it calls getAndClearPendingCookies() and renderFallbackPage() → renderHttpAccessFallbackPage(), which performs a full RSC+SSR re-render of the not-found/forbidden/unauthorized boundary (with layouts) that can call headers()/cookies(). It then clears the context itself at the end (L333/L459).

On main this path returned options.renderPageSpecialError(specialError) without clearing context first. This PR adds the early clear, so a notFound()/forbidden() thrown after the shell (the hasLoadingBoundary || !probePageBeforeRender branch) will render its boundary with a null request context. This is exactly the class of bug that issue #660 warns about.

The same early clear happens at the htmlRender.response alternate-response path (~L983); that one is lower-risk since the shell-recovery response is already materialized, but it's worth confirming it doesn't read context lazily.

Suggested fix: don't clear the request context on paths that delegate to renderPageSpecialError (let that helper own the clear, as it does today). Only abortRscRender is needed there, not clearRequestContext. Consider splitting finalizeRenderLifecycle into "abort the RSC render + detach listeners" vs "clear request context" so the special-error path can abort without prematurely tearing down context.

Please add a test that throws notFound() (or forbidden()) late — after the shell, with a loading boundary — from a route whose boundary reads cookies()/headers(), and assert the boundary still sees the context. The existing suite passes but I don't see coverage for context-reading inside a late special-error boundary.

2. trackStreamCompletion never resolves on cancel

packages/vinext/src/server/defer-until-stream-consumed.ts:

cancel(reason) {
  return reader.cancel(reason); // complete() is never called
},

If the wrapped flight stream is cancelled before being fully read (client disconnect, or both tee branches cancelled), onComplete (resolveFlightCompletion) never fires, so pendingMetadataPlacement stays unresolved forever. In practice the only awaiter is PendingAppMetadata inside an SSR render that is itself being torn down, so this is a dangling-promise rather than a hard hang today — but it's asymmetric with the sibling deferUntilStreamConsumed, which you deliberately taught to fire its onAbort on cancel/error in this same PR. Recommend calling complete() (or resolving placement to a safe default) in cancel for consistency and to avoid a latent hang if a future caller awaits this promise outside the SSR scope.

3. Double metadata render across SSR and BrowserRoot (please confirm hydration parity)

On initial load, metadata is rendered by PendingAppMetadata (SSR, app-ssr-entry.ts) and also by BrowserRoot (app-browser-entry.ts L1050) when pendingMetadata is present in the initial tree state. These are two different component trees emitting the same hoistable <title>/<meta> tags, relying on React 19 metadata hoisting to dedupe. This generally works, but the markup must match exactly (same pathname/trailingSlash, same tag order) to avoid a hydration mismatch. The browser spec should be covering this; please confirm there are no console hydration warnings on the static/ISR routes (not just navigation). If BrowserRoot only needs to render this on client-side navigation, gating it on a non-initial render would remove the duplication risk entirely.

Minor

  • app-page-dispatch.ts ~L896: the nested ternary for metadataPlacement (VINEXT_PRERENDER === "1" || shouldRenderSharedArtifact ? (VINEXT_PRERENDER === "1" ? "head" : "head-if-static") : undefined) is correct but hard to read and evaluates process.env.VINEXT_PRERENDER twice. A small if/else or a helper would be clearer.
  • app-page-route-wiring.tsx createAppPageRouteBodyMetadata: the refactor to const metadataHtml = (...); return metadataHtml; is a no-op vs returning the JSX directly. Fine, just noise in the diff.

What looks good

  • The head-if-static wire path (app-elements-wire.ts) with readPendingMetadata validation, and the slot.tsx guard so transported pending-metadata isn't rendered as a slot element — both well done and tested.
  • Backpressure test (does not drain the Flight stream ahead of a slow HTML consumer) and the abort-on-cancel / abort-on-request-abort tests are exactly the right coverage for the new lifecycle.
  • ISR MISS/HIT/STALE/regen head-metadata test and the parallel/default-slot dynamic body cases match the Next.js v16.2.6 behavior and are linked to the upstream tests. Good parity discipline.

Overall: addressing #1 (early context clear on the special-error path) is the blocker; #2 and #3 are correctness-hardening I'd like confirmed before merge.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2330. Summary of findings:

Review summary

I reviewed the metadata-placement deferral, the new stream/lifecycle plumbing, and the head-if-static wire path. The approach is sound and well-tested, but I flagged one likely regression and two correctness-hardening items.

1. Request context cleared too early on the late special-error path (blocker) — app-page-render.ts:1020 now calls finalizeRenderLifecycle(specialError) → clearRequestContext() before renderPageSpecialError() runs. That helper still needs the live context (it reads pending cookies and re-renders the not-found/forbidden boundary, which can call headers()/cookies()). On main the context wasn't cleared on this path. This is exactly the issue #660 class of bug. Recommended splitting the abort vs. clear responsibilities so special-error delegation only aborts the RSC render. Asked for a test covering a late notFound() boundary that reads context.

2. trackStreamCompletion never resolves on cancel — its cancel() doesn't call complete(), leaving pendingMetadataPlacement permanently unresolved on cancellation. Today the only awaiter is a torn-down SSR render, so it's a dangling promise rather than a hard hang, but it's inconsistent with the sibling deferUntilStreamConsumed's new onAbort behavior added in the same PR.

3. Double metadata render (SSR PendingAppMetadata + BrowserRoot MetadataHead) — relies on React 19 hoisting dedup; asked to confirm no hydration warnings on static/ISR routes and whether BrowserRoot could gate to navigation-only.

Plus two minor nits (nested ternary readability in dispatch; a no-op refactor in createAppPageRouteBodyMetadata). Called out the strong test coverage (backpressure, abort paths, ISR head metadata, Next.js v16.2.6 parity) as what looks good.

I verified the targeted unit/integration tests pass (app-page-render, slot, app-page-element-builder, app-router-production-server — 183 tests) and vp check is clean on the changed files.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/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: classify static metadata after rendering (re-review @ f23179e)

I re-reviewed the full diff against main and traced the new metadata-deferral + lifecycle plumbing end to end. The approach is sound and the test coverage (ISR head, body-dynamic, parallel/default slots, backpressure, abort paths, Next.js v16.2.6 parity) is strong. The latest commit (f23179e, after the previous review) only reverts PageInvoker to sync and makes APP_PENDING_METADATA_KEY module-private — it does not address the three concerns from the prior review, all of which still stand on the current HEAD.

1. Request context cleared too early on the late special-error path (blocker — still present)

app-page-render.ts:1014-1024:

if (options.hasLoadingBoundary || !probePageBeforeRender) {
  const captured = rscErrorTracker.getCapturedSpecialError();
  if (captured) {
    const specialError = resolveAppPageSpecialError(captured);
    if (specialError) {
      void htmlStream.cancel().catch(() => {});
      finalizeRenderLifecycle(specialError);          // <- clears request context
      return options.renderPageSpecialError(specialError);
    }
  }
}

finalizeRenderLifecycle() (L711-720) calls options.clearRequestContext(), which is clearAppRequestContext() → setHeadersContext(null) + setAppNavigationContext(null) (app-request-context.ts:27-30). This PR adds that clear before renderPageSpecialError runs; on main the path returned renderPageSpecialError(specialError) with the context still live.

renderPageSpecialError → buildAppPageSpecialErrorResponse (app-page-execution.ts:329) reads pending cookies (getAndClearPendingCookies(), L312/396/419) and, for notFound()/forbidden()/unauthorized(), calls renderFallbackPage() → renderHttpAccessFallback (app-fallback-renderer.ts:201), which does a full RSC+SSR re-render of the boundary. That re-render path (app-page-boundary-render.ts:296-320) reconstructs a navigation-context fallback but does not re-set the headers context — so a not-found.tsx/forbidden.tsx boundary that calls cookies()/headers() will see a null headers context. This is the issue #660 class of bug.

This path is reachable in production: it fires for routes with a loading.tsx (hasLoadingBoundary) or page-probe-skipped routes that throw notFound()/forbidden() late, after the shell.

I confirmed the regression with a focused test (ordering of clearRequestContext vs renderPageSpecialError):

- Expected   ["renderPageSpecialError", "clearRequestContext"]
+ Received    ["clearRequestContext",   "renderPageSpecialError"]

The existing renderPageSpecialError test mock returns a static Response without reading context, so the current suite cannot catch this.

Suggested fix: don't clearRequestContext on paths that delegate to renderPageSpecialError — that helper already owns the clear (app-page-execution.ts:333/459, and after the boundary render). Split finalizeRenderLifecycle into "abort RSC render + detach listeners" vs "clear request context" so the special-error path can abort without tearing down context. Please also add a test that throws notFound()/forbidden() late from a route whose boundary reads cookies()/headers() and asserts the boundary still sees the context.

The htmlRender.response alternate-response path (L982-985) adds the same early clear; lower risk since that response is already materialized, but worth confirming it doesn't read context lazily.

2. trackStreamCompletion never resolves on cancel (still present)

defer-until-stream-consumed.ts:76-78:

cancel(reason) {
  return reader.cancel(reason);   // complete() is never called
},

If the wrapped flight stream is cancelled before being fully read (client disconnect, both tee branches cancelled), onComplete never fires, so pendingMetadataPlacement (app-page-render.ts:755) stays unresolved forever. Today the only awaiter is PendingAppMetadata inside an SSR render that is itself being torn down, so it's a dangling promise rather than a hard hang — but it's asymmetric with the sibling deferUntilStreamConsumed, which this same PR deliberately taught to fire on cancel/error (L41-45). Recommend calling complete() in cancel for consistency and to avoid a latent hang if a future caller awaits this promise outside the SSR scope.

3. Double metadata render across SSR and BrowserRoot (looks covered, one gap)

On initial load, pendingMetadata is rendered by PendingAppMetadata (SSR, app-ssr-entry.ts:424-429) and by BrowserRoot (app-browser-entry.ts:1047-1054) — two trees emitting the same hoistable <title>/<meta>, relying on React 19 hoisting dedup.

The new browser spec is good evidence this works: it asserts head > title count is exactly 1 on initial load and after navigation (metadata-static-navigation.browser.spec.ts:144,156,169). The remaining gap is that no test asserts the absence of console hydration warnings on the static/ISR initial render. Please confirm there are none (or assert it in the spec). If BrowserRoot only needs MetadataHead for client-side navigation, gating it to non-initial renders would remove the duplication risk entirely.

Minor

  • app-page-dispatch.ts:896-901: the nested ternary for metadataPlacement (VINEXT_PRERENDER === "1" || shouldRenderSharedArtifact ? (VINEXT_PRERENDER === "1" ? "head" : "head-if-static") : undefined) is correct but hard to read and evaluates process.env.VINEXT_PRERENDER twice. A small if/else or helper would be clearer.
  • app-page-route-wiring.tsx:528-536 createAppPageRouteBodyMetadata: the const metadataHtml = (...); return metadataHtml; refactor is a no-op vs returning the JSX directly — just diff noise.

What looks good

  • head-if-static wire path (app-elements-wire.ts readPendingMetadata validation) + the slot.tsx isPendingMetadataValue guard so transported pending-metadata isn't rendered as a slot element — both well done and tested.
  • Backpressure test and the abort-on-cancel / abort-on-request-abort coverage are exactly the right tests for the new lifecycle.
  • ISR MISS/HIT/STALE/regen head-metadata test and the parallel/default-slot dynamic body cases match Next.js v16.2.6 and link to the upstream tests. Good parity discipline.

Verification: ran tests/app-page-render.ts, tests/slot.test.ts, tests/app-elements.test.ts, tests/app-page-element-builder.test.ts, tests/app-router-production-server.test.ts — all green (261 tests). The blocker (#1) is not caught by the existing suite for the reason noted above.

Overall: #1 is the blocker before merge; #2 is a small consistency fix; #3 just needs the hydration-warning confirmation.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2330.

Summary

I re-reviewed the full diff against main and traced the metadata-deferral + lifecycle plumbing end to end. The approach is sound and well-tested, but I confirmed the three concerns from the prior review still stand on the current HEAD (f23179e — the latest commit only reverted PageInvoker to sync and made a wire key private; it did not address any of them).

1. Request context cleared too early on the late special-error path (blocker). app-page-render.ts:1020 now calls finalizeRenderLifecycle(specialError) → clearRequestContext() before renderPageSpecialError(). That helper re-renders the not-found/forbidden boundary via renderHttpAccessFallback, which does not re-set the headers context — so a boundary calling cookies()/headers() sees a null context. This is the issue #660 class of bug. I verified it objectively with a focused test: the ordering is ["clearRequestContext", "renderPageSpecialError"] (should be the reverse). The existing suite can't catch it because its renderPageSpecialError mock returns a static Response without reading context.

2. trackStreamCompletion never resolves on cancel. defer-until-stream-consumed.ts:76-78 cancel() doesn't call complete(), leaving pendingMetadataPlacement permanently unresolved — asymmetric with the sibling deferUntilStreamConsumed taught to fire on cancel in this same PR.

3. Double metadata render (SSR + BrowserRoot). Relies on React 19 hoisting dedup. The browser spec asserts head > title count == 1 (good evidence it works), but no test asserts the absence of console hydration warnings on the static/ISR initial render — asked to confirm.

Plus two minor nits (nested ternary readability in dispatch; a no-op refactor in createAppPageRouteBodyMetadata). Verified the targeted unit/integration suites pass (261 tests).

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review: classify static metadata after rendering (re-review @ 23b6db3)

Re-reviewed the full diff against main and traced the metadata-deferral + lifecycle plumbing end to end. The latest commit (23b6db3, "finalize late render lifecycle safely") resolves both blockers from the prior reviews. Verified the fixes and that the changed files pass vp check. One correctness-hardening item (#3) still stands.

1. Request context cleared too early on the late special-error path — RESOLVED ✅

23b6db3 splits the lifecycle into two responsibilities (app-page-render.ts:712-729):

  • finalizeRenderLifecycle(abortReason?) — aborts the RSC render + detaches abort listeners only.
  • clearRequestContext() — guarded, nulls the headers/navigation context.
  • completeRenderLifecycle() — does both (used on the request-abort, catch, and stream-consumed paths).

The late special-error path (app-page-render.ts:1028-1035) now calls finalizeRenderLifecycle(specialError) (abort-only) before renderPageSpecialError, and only clears context on the error path:

void htmlStream.cancel().catch(() => {});
finalizeRenderLifecycle(specialError);   // aborts RSC, does NOT clear context
try {
  return await options.renderPageSpecialError(specialError);
} catch (error) {
  clearRequestContext();
  throw error;
}

So a late notFound()/forbidden() boundary that reads cookies()/headers() now sees a live context. There's also a new regression test (app-page-render.test.ts:1432-1494, "keeps request headers and cookies available while rendering a late special error") that asserts headers()/cookies() resolve and clearRequestContext has not been called while renderPageSpecialError streams its boundary — exactly the coverage the prior review asked for. This is the issue #660 class of bug, now closed.

2. trackStreamCompletion never resolves on cancel — RESOLVED ✅

defer-until-stream-consumed.ts:76-79 now calls complete() in cancel():

cancel(reason) {
  complete();
  return reader.cancel(reason);
},

pendingMetadataPlacement no longer dangles on cancellation, and it's now symmetric with the sibling deferUntilStreamConsumed's onAbort-on-cancel behavior added in this same PR.

3. Double metadata render across SSR and BrowserRoot — please confirm body-placement hydration parity (still open)

This is the one remaining concern. On initial load pendingMetadata is rendered by two independent trees:

  • SSR: PendingAppMetadata (app-ssr-entry.ts:427-431, app-pending-metadata.tsx), which honors the resolved placement — <MetadataHead> for "head", but a hidden <div dangerouslySetInnerHTML> in the body for "body".
  • Client: BrowserRoot (app-browser-entry.ts:1050-1056) renders MetadataHead unconditionally (always hoists <title>/<meta> to <head>), with no placement gate.

For the static/head case both produce hoisted head tags and React 19 dedup handles it — the new browser spec confirms head > title count == 1 on load and after navigation (metadata-static-navigation.browser.spec.ts:144,156,169). Good.

For the body/dynamic case there's an SSR↔client divergence: pendingMetadata is attached to the wire whenever metadataPlacement === "head-if-static" && resolvedMetadata (app-page-route-wiring.tsx:625-634) — i.e. for every runtime request with resolved metadata, regardless of whether placement later resolves to "body". So on a dynamic-metadata route (e.g. metadata-streaming-body-dynamic), SSR emits the title in a hidden body div (raw HTML, not React-hoistable), while the hydrating BrowserRoot renders MetadataHead into the head. The two trees disagree on where the metadata lives.

The new server-side tests (app-router-production-server.test.ts) only assert the SSR HTML body/head placement; the browser spec only exercises static routes. No test asserts the absence of console hydration warnings on a body-placement (dynamic-metadata) initial render, which is exactly where this asymmetry would surface.

Please either:

  • add a browser test that loads a body-placement route (e.g. metadata-streaming-body-dynamic) and asserts no hydration warnings + a single head > title post-hydration, or
  • gate BrowserRoot's MetadataHead to non-initial (client-navigation) renders, or have it honor the resolved placement the way PendingAppMetadata does — which would remove the duplication/divergence risk entirely.

Minor (unchanged, optional)

  • app-page-dispatch.ts:896-901: the nested ternary for metadataPlacement (VINEXT_PRERENDER === "1" || shouldRenderSharedArtifact ? (VINEXT_PRERENDER === "1" ? "head" : "head-if-static") : undefined) is correct but hard to read and evaluates process.env.VINEXT_PRERENDER twice — a small if/else or helper would read better.
  • app-page-route-wiring.tsx:528-536 createAppPageRouteBodyMetadata: the const metadataHtml = (...); return metadataHtml; is a no-op vs returning the JSX directly — just diff noise.

What looks good

  • The lifecycle split (finalizeRenderLifecycle vs clearRequestContext vs completeRenderLifecycle) is clean and the new late-special-error regression test is precisely the right coverage.
  • head-if-static wire path (app-elements-wire.ts readPendingMetadata validation) + the slot.tsx isPendingMetadataValue guard so transported pending-metadata isn't rendered as a slot element — both well done and tested.
  • Backpressure test and abort-on-cancel / abort-on-request-abort coverage are exactly right for the new lifecycle.
  • ISR MISS/HIT/STALE/regen head-metadata test and the parallel/default-slot dynamic body cases match Next.js v16.2.6 and link to the upstream tests. Good parity discipline.

Verification: ran tests/app-page-render.test.ts (56 incl. the new late-special-error test), tests/slot.test.ts, tests/app-elements.test.ts — all green; vp check clean on the changed server files.

Overall: the two blockers are now fixed. #3 is the only remaining item — just needs the body-placement hydration-parity confirmation (or the BrowserRoot placement gate) before merge.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Posted the review to PR #2330.

Summary

I re-reviewed the full diff at the current HEAD (23b6db3) and traced the metadata-deferral + lifecycle plumbing end to end. The latest commit resolved both blockers from the prior reviews, which I verified directly in code and tests:

1. Late special-error context clear (was the blocker) — FIXED. 23b6db3 splits the lifecycle into finalizeRenderLifecycle (abort RSC + detach listeners) vs clearRequestContext vs completeRenderLifecycle. The late notFound()/forbidden() path now calls finalizeRenderLifecycle(specialError) (abort-only) before renderPageSpecialError, only clearing context on the error path (app-page-render.ts:1028-1035). A new regression test (app-page-render.test.ts:1432-1494) asserts headers()/cookies() are live and clearRequestContext hasn't run while the boundary streams — exactly the issue #660 coverage the prior reviews requested.

2. trackStreamCompletion never resolved on cancel — FIXED. cancel() now calls complete() (defer-until-stream-consumed.ts:76-79), symmetric with the sibling helper.

3. Double metadata render (SSR + BrowserRoot) — still open. SSR's PendingAppMetadata honors the resolved placement (head tags or a hidden body div), but BrowserRoot renders MetadataHead unconditionally to head. Since pendingMetadata rides the wire for every runtime request with resolved metadata, body-placement (dynamic-metadata) routes have an SSR-body vs client-head divergence. The new server tests only assert SSR HTML; the browser spec only covers static routes — no test asserts the absence of hydration warnings on a body-placement initial render. Asked them to add that test or gate/align BrowserRoot's MetadataHead.

Plus two unchanged minor nits (nested ternary in dispatch, no-op refactor). Verified targeted suites pass and vp check is clean on the changed server files.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@james-elicx

Copy link
Copy Markdown
Member Author

/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: classify static metadata after rendering (re-review @ b3e041a)

Re-reviewed the full diff against main and traced the metadata-deferral + lifecycle plumbing end to end. The latest commit (b3e041a, "align metadata hydration placement") resolves the last open concern (#3). All three items from the prior reviews are now addressed. Verified the fixes in code/tests and that the changed files pass vp check.

1. Late special-error context clear (was the blocker) — RESOLVED ✅

23b6db3 split the lifecycle into finalizeRenderLifecycle (abort RSC + detach listeners) vs clearRequestContext vs completeRenderLifecycle. The late special-error path (app-page-render.ts:1028-1035) now calls finalizeRenderLifecycle(specialError) (abort-only) before renderPageSpecialError, and only clears context on the error path. The regression test (app-page-render.test.ts, "keeps request headers and cookies available while rendering a late special error") confirms headers()/cookies() stay live and clearRequestContext hasn't run while the boundary streams. Issue-#660 class closed.

2. trackStreamCompletion never resolves on cancel — RESOLVED ✅

defer-until-stream-consumed.ts:76-79 cancel() now calls complete(), so pendingMetadataPlacement no longer dangles — symmetric with the sibling deferUntilStreamConsumed's onAbort-on-cancel.

3. Double metadata render across SSR and BrowserRoot — RESOLVED ✅

b3e041a removes the SSR↔client divergence two ways:

  • BrowserRoot no longer renders MetadataHead on initial hydration — it is now gated to client navigation only (app-browser-entry.ts:1053: treeState.renderId !== 0 && treeMetadata.pendingMetadata). So on the initial render the client tree produces no head metadata, leaving the SSR-emitted placement (head tags for static, hidden body <div> for dynamic) untouched. This eliminates the body-placement case where SSR put the title in the body while the hydrating client put it in the head.
  • SSR marks the body div and BrowserRoot cleans it up on navigation — PendingAppMetadata now tags its hidden div with data-vinext-pending-metadata="body" (app-pending-metadata.tsx:28), and the navigation layout effect removes it when renderId !== 0 (app-browser-entry.ts:1033-1035). On navigation the new metadata hoists into <head> and the stale body div is dropped.

I confirmed the eager body-metadata source (createAppPageRouteBodyMetadata, wiring) and the deferred one (PendingAppMetadata, head-if-static) are mutually exclusive per metadataPlacement, so there's never a double body div, and only the deferred (marked) div needs the explicit querySelector().remove() — the eager one rides in the route element subtree and is reconciled by React on navigation.

Most importantly, the new browser test (metadata-static-navigation.browser.spec.ts:156-201, ported from Next.js v16.2.6 ppr-metadata-streaming) is exactly the body-placement hydration-warning coverage the prior reviews asked for:

  • asserts SSR HTML puts dynamic metadata in the body, not head;
  • after hydration asserts head > title == 0, body title == 1, and consoleErrors is empty (hydration mismatches surface as console.error, and the consoleErrors fixture does not filter them — verified in tests/e2e/fixtures.ts:14-27);
  • after navigation asserts metadata moves to <head> (count 1), the body div is gone, and again no console errors.

This matches Next.js behavior (dynamic metadata streams into the body and only relocates to head on client navigation) and is linked to the upstream test. Good parity discipline.

Minor (unchanged, optional)

  • app-page-dispatch.ts:896-901: the nested ternary for metadataPlacement is correct but hard to read and evaluates process.env.VINEXT_PRERENDER twice — a small if/else or helper would read better.
  • app-page-route-wiring.tsx:528-536 createAppPageRouteBodyMetadata: const metadataHtml = (...); return metadataHtml; is a no-op vs returning the JSX directly — diff noise.

What looks good

  • Lifecycle split (finalizeRenderLifecycle / clearRequestContext / completeRenderLifecycle) is clean, and the late-special-error regression test is precisely the right coverage.
  • head-if-static wire path (app-elements-wire.ts readPendingMetadata validation) + the slot.tsx isPendingMetadataValue guard so transported pending-metadata isn't rendered as a slot element — both well done and tested.
  • Hydration-placement gating in BrowserRoot + the marked body div cleanup is a clean way to remove the SSR/client divergence without losing the "move to head on navigation" behavior.
  • Backpressure test and abort-on-cancel / abort-on-request-abort coverage are exactly right for the new lifecycle.
  • ISR MISS/HIT/STALE/regen head-metadata test and the parallel/default-slot dynamic body cases match Next.js v16.2.6 and link to the upstream tests.

Verification: ran tests/app-page-render.test.ts (56), tests/slot.test.ts (25), tests/app-elements.test.ts (78), tests/app-page-element-builder.test.ts (39), tests/app-router-production-server.test.ts + tests/app-page-stream.test.ts (89) — all green; vp check clean on the changed server files.

Overall: all three concerns are now resolved. Only the two optional readability nits remain. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2330.

Summary

I re-reviewed the full diff at the current HEAD (b3e041a) and traced the metadata-deferral + lifecycle plumbing end to end. The latest commit resolved the last open concern, so all three items from prior reviews are now addressed:

1. Late special-error context clear (original blocker) — RESOLVED. 23b6db3 split the lifecycle so the late notFound()/forbidden() path aborts the RSC render but keeps the request context live for renderPageSpecialError, with a regression test asserting headers()/cookies() stay available (issue #660 class).

2. trackStreamCompletion never resolved on cancel — RESOLVED. cancel() now calls complete().

3. Double metadata render (SSR + BrowserRoot) — RESOLVED in b3e041a. Two changes eliminate the SSR↔client divergence:

  • BrowserRoot now gates MetadataHead to client-navigation only (renderId !== 0), so initial hydration produces no client head metadata and leaves the SSR placement intact.
  • PendingAppMetadata marks its body div (data-vinext-pending-metadata="body") and BrowserRoot removes it on navigation.

Critically, the new browser test (ported from Next.js v16.2.6 ppr-metadata-streaming) asserts a body-placement (dynamic-metadata) route hydrates with no console errors (which catches hydration mismatches via the consoleErrors fixture), metadata stays in the body, and only moves to <head> on client navigation — exactly the coverage prior reviews requested, and matching Next.js behavior.

I verified the eager vs deferred body-metadata sources are mutually exclusive (no double body div), ran the targeted unit/integration suites (287 tests, all green), and confirmed vp check is clean on the changed files. Only the two optional readability nits remain. LGTM.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 25, 2026 13:24
@james-elicx

Copy link
Copy Markdown
Member Author

Worker B note: I checked this against GitHub Actions run 28478866791 / job 84413308650. This PR appears to cover the metadata-static-generation and metadata-streaming-static-generation failures in that run: the touched runtime path is the async/static metadata placement/classification path, and the PR validation already calls out the exact static-generation suite. I am treating those suites as covered here and will skip duplicating this fix.

@james-elicx

Copy link
Copy Markdown
Member Author

Deploy-suite run 28478866791 / job 84413308650 triage: I think this PR already covers the static metadata title failures from test/e2e/app-dir/metadata-static-generation/metadata-static-generation.test.ts and test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts where async generated metadata produced an empty <title>. I am skipping a duplicate backlog fix for that item and leaving this note so the overlap is visible.

@james-elicx

Copy link
Copy Markdown
Member Author

This appears to cover run 28478866791 / job 84413308650 for the App Router metadata-static-generation cluster, including the failing assertions around async generated metadata in the head for simple static pages and suspenseful static content. I am not opening a duplicate fix for this cluster.

@james-elicx

Copy link
Copy Markdown
Member Author

This looks like it likely covers the non-cacheComponents metadata lane from run 28478866791 / job 84413308650: metadata-static-generation and metadata-streaming-static-generation are failing because async generated static metadata titles are missing from <head> (empty title instead of index page, suspenseful page - static, etc.). The PR summary/validation here maps directly to that failure shape, and it explicitly excludes the separate cacheComponents/PPR/resume and metadata-icons lanes.

@james-elicx

Copy link
Copy Markdown
Member Author

Targeted follow-up for run 28478866791 / report job 84413308650: I checked the exact metadata-streaming-static-generation target on this PR head (b3e041a128403277f0e3efc52c2f05660460b094).

Command used from a clean proof worktree:

PATH="/Users/jamesanderson/.vite-plus/js_runtime/node/24.15.0/bin:/Users/jamesanderson/.vite-plus/bin:$PATH" \
REPO="$(pwd)" \
NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" \
NEXTJS_PREPARE=0 \
VINEXT_BUILD=0 \
NEXT_TEST_CONCURRENCY=1 \
./scripts/run-nextjs-deploy-suite.sh "/Users/jamesanderson/Developer/vinext/.nextjs-ref" \
  --retries 0 \
  -c 1 \
  test/e2e/app-dir/metadata-streaming-static-generation/metadata-streaming-static-generation.test.ts

Result: failed, 5 passed / 2 failed.

The three original static metadata rows from run 28478866791 now pass on this branch, but the full targeted file still fails:

  • dynamic pages with html limited bots should contain stream metadata in head for suspenseful dynamic page — expected suspenseful page - dynamic, received an empty head title
  • dynamic pages with html limited bots should contain async generated metadata in head for simple dynamic page — expected slow page - dynamic, received an empty head title

So I am still treating this PR as the owner for the original static rows and skipping duplicate implementation work, but I would not count the target as exact-proofed until this file passes end to end.

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.

1 participant