Skip to content

fix: preserve csp-nonce on mergeHead, warn on dropped streamed resolve#1052

Merged
vivek7405 merged 2 commits into
mainfrom
fix/mergehead-csp-nonce-resolve-warn
Jul 22, 2026
Merged

fix: preserve csp-nonce on mergeHead, warn on dropped streamed resolve#1052
vivek7405 merged 2 commits into
mainfrom
fix/mergehead-csp-nonce-resolve-warn

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Two small client-router fixes found during the #1049 review, both in packages/core/src/router-client.js.

Closes #1050 - mergeHead replaced the csp-nonce meta on a background full-body swap

mergeHead (used only on the background in-place full-body path: a popstate snapshot restore or its revalidation) removed the live <meta name="csp-nonce"> and appended the incoming one whenever the per-request nonce differed (which it always does on a CSP-enabled app). That violates getCspNonce()'s contract, the browser enforces CSP against the nonce the ORIGINAL page load declared, so after the merge later cloneScriptWithCorrectNonce / cloneElementWithCorrectNonce-stamped scripts and modulepreloads got a nonce the active policy blocks. Same bug class #1049 fixed for the boundary/frame paths (reconcileHeadMetas excludes META_KEY_CSP_NONCE); mergeHead predates it. Fix: skip csp-nonce in both mergeHead loops.

Closes #1051 - dev-warn when a streamed Suspense resolve is dropped

applyStreamedResolve silently drops a resolve whose boundary placeholder is absent. The drop is the correct designed posture (a superseded / degraded / discarded nav), and this PR does NOT change it (no retry, which would run outside the nav-token fence). But the silence is exactly what made the #1048 view-transition race take a full live-repro investigation. Add a dev-only, fire-once-per-id warnDropped(id) naming the boundary, mirroring the existing devWarnFallback dev-diagnostic. Never warns in production, never throws.

Tests

  • Unit (packages/core/test/routing/router-client.test.js): mergeHead keeps the original nonce (identity + content + a created script stamped with the original), no duplicate; the dropped-resolve warns once in dev, is silent on a successful resolve and in production. 185/185 pass.

Definition-of-done surfaces

  • Docs: N/A because neither change touches a public/authoring surface (a background-path correctness fix + a dev-only console diagnostic).
  • Dogfood: website / docs / ui-website boot 200 with no broken preloads; blog e2e 78/78 (dist mode, rebuilt).
  • Browser layer: N/A because both changes are DOM-head-merge + console logic fully modeled by the node/linkedom harness (no headline browser behavior beyond the streaming-resolve path fix: view-transition soft-nav meta leak and stuck Suspense skeleton #1049's browser suite already covers).
  • Self-review loop converged (last round clean).

#1050: mergeHead (the background full-body swap path) treated the live
csp-nonce meta as stale whenever the incoming response carried a different
per-request nonce, removing it and appending the incoming one. The browser
enforces CSP against the ORIGINAL page-load nonce, so getCspNonce() then
returned a nonce the active policy rejects and later nonce-stamped
scripts/preloads were blocked. Skip csp-nonce in both mergeHead loops, the
same carve-out reconcileHeadMetas already has.

#1051: applyStreamedResolve silently drops a resolve whose boundary is
absent. The drop is correct, but the silence is why the #1048 view-transition
race was hard to diagnose. Add a dev-only, fire-once-per-id warning naming the
dropped boundary; no behavior change, suppressed in production.
@vivek7405 vivek7405 self-assigned this Jul 22, 2026
Review follow-ups: guard the csp-nonce skip in both mergeHead loops on
tagName === 'META' (consistency with reconcileHeadMetas, closes a theoretical
non-meta name=csp-nonce hole), drop a semicolon pause in the warnDropped
message, and add the incoming-omits-csp-nonce mergeHead case.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Read through both fixes. The mergeHead csp-nonce carve-out and the dev-warn are correct and the tests hold. Three small things worth tightening: the nonce skip is called on every head child without a tag guard, the warn message has a semicolon pause, and the mergeHead test only covered the both-present case. All three addressed.

Comment thread packages/core/src/router-client.js
Comment thread packages/core/src/router-client.js
Comment thread packages/core/test/routing/router-client.test.js

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Went back over the mergeHead carve-out with the tag guard, the warn gating, and the added test. All correct, no interaction with the #936 stylesheet preservation, counterfactuals hold. Nothing left.

@vivek7405
vivek7405 marked this pull request as ready for review July 22, 2026 18:44

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

One more pass over the whole diff. Confirmed the production gate actually holds in browsers (the server injects process.env.NODE_ENV browser-side, ssr.js:1197, and the gate is byte-identical to devWarnFallback), the carve-out composes with the addNewHeadElements/reconcileHeadMetas exclusions so no path adds or removes a csp-nonce meta on any swap tier, and the streamer reaches the warn only past the isCurrent fence, so it stays quiet on routine superseded navs. One semantics note, accepted deliberately: warnOnce keys per id per page session, and boundary ids recycle across pages, so a second distinct s1 drop in one session is silent. That matches devWarnFallback convention, and dev iteration reloads reset it, so the first occurrence after any reload always surfaces. Nothing to change.

@vivek7405
vivek7405 merged commit a32353e into main Jul 22, 2026
19 of 20 checks passed
@vivek7405
vivek7405 deleted the fix/mergehead-csp-nonce-resolve-warn branch July 22, 2026 18:55
vivek7405 added a commit that referenced this pull request Jul 22, 2026
Add a 'CSP and the client router' subsection to the security docs page and a
paragraph to the built-ins reference: enforcement is the HTTP header (not a
meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on
a soft navigation the original page-load nonce stays authoritative and is
re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives
users setting up strict CSP the why behind the model.
vivek7405 added a commit that referenced this pull request Jul 22, 2026
…ns (#1055)

* docs: explain the CSP nonce model across client-router soft navigations

Add a 'CSP and the client router' subsection to the security docs page and a
paragraph to the built-ins reference: enforcement is the HTTP header (not a
meta http-equiv tag), the meta name=csp-nonce is a client-side carrier, and on
a soft navigation the original page-load nonce stays authoritative and is
re-stamped onto every dynamically-inserted script/preload (#1049/#1052). Gives
users setting up strict CSP the why behind the model.

* docs: scope the soft-nav nonce claim to the document, not the session

Review: 'the ORIGINAL nonce stays authoritative for the whole session'
overstated. It holds for the life of the current document. A hard reload or
full page load creates a new document with the response's own fresh nonce.
Reword and add the harmless-full-load case so soft-nav vs hard-reload is
clear (the built-ins reference already stated this conservatively).
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.

Dev-warn when a streamed Suspense resolve is dropped (absent boundary) mergeHead replaces the csp-nonce meta on a background full-body swap

1 participant