Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,24 @@ function devWarnFallback(cause, href) {
);
}

/**
* Dev-only, fire-once-per-id hint: a streamed Suspense resolution arrived but
* its boundary placeholder was not in the DOM, so it was dropped (#1051). This
* is benign when the navigation was superseded, degraded to a full load, or
* discarded, but a stuck skeleton that is NONE of those has no other signal (it
* is what made the #1048 view-transition race hard to diagnose). Never warns in
* production, never throws.
*
* @param {string} id the streamed boundary id that could not be applied
*/
function warnDropped(id) {
if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
warnOnce(
`stream-drop:${id}`,
`[webjs] dropped a streamed Suspense resolve for "${id}": no #${id} boundary in the DOM. Benign if this navigation was superseded or degraded. A stuck skeleton here means the shell swap did not place the boundary.`
Comment thread
vivek7405 marked this conversation as resolved.
);
}

/**
* Dev-only, fire-once hint: the router forces an INSTANT scroll-to-top on a
* forward navigation (matching a native page load), so an app-level
Expand Down Expand Up @@ -3937,13 +3955,23 @@ function mergeHead(newHead) {
// headline #936 symptom. Keeping it is safe: a genuinely stale sheet is
// dropped by the deploy-level hard reload (build-id mismatch), not here.
if (isPersistentHeadStyle(el)) continue;
// Never remove the CSP nonce meta: the incoming full-body response carries a
// FRESH per-request nonce, but the browser enforces CSP against the nonce the
// ORIGINAL page load declared (see `getCspNonce`), so the live one must stay
// (#1050). `outerHTMLForDiff` strips the nonce ATTRIBUTE but not the `content`
// it lives in on this meta, so without this it looks "changed" and is dropped.
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
Comment thread
vivek7405 marked this conversation as resolved.
if (!newSet.has(outerHTMLForDiff(el))) el.remove();
}

for (const el of newHead.children) {
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
if (el.tagName === 'BASE') continue;
if (el.tagName === 'TITLE') continue;
// Do not append the incoming per-request csp-nonce meta (the live original is
// kept above), or the head would carry two and `getCspNonce` could read the
// wrong one (#1050).
if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
if (!currentSet.has(outerHTMLForDiff(el))) {
if (el.tagName === 'SCRIPT') {
currentHead.appendChild(
Expand Down Expand Up @@ -4113,7 +4141,15 @@ function applyStreamedResolve(id, content) {
// resolve is applied. A retry here would run OUTSIDE the streamer's
// `isCurrent()` nav-token fence and could splice a superseded nav's content
// into a recycled boundary id, so it is deliberately not attempted.
if (!boundary) return;
if (!boundary) {
// Dev-only diagnostic (#1051): the drop is benign for the normal reasons (a
// superseded / degraded / discarded nav), but a stuck skeleton that ISN'T
// one of those is otherwise silent, which is exactly what made #1048 hard to
// find. Surface the dropped boundary so a future regression is one glance
// away. Never in production, never throws, once per id.
warnDropped(id);
return;
}
const tpl = document.createElement('template');
tpl.innerHTML = content;
const inserted = [...tpl.content.childNodes];
Expand Down
80 changes: 80 additions & 0 deletions packages/core/test/routing/router-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile,
_eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake,
_prefetchSaysSaveData, _prefetchPeek, _prefetchInflightSize, _resetPrefetch,
_viewTransitionsEnabled, _runWithTransition, _regraftPermanentElements,
_applyStreamedResolve,
enableClientRouter, disableClientRouter, revalidate,
WebComponent, html;

Expand Down Expand Up @@ -114,6 +115,7 @@ before(async () => {
_viewTransitionsEnabled,
_runWithTransition,
_regraftPermanentElements,
_applyStreamedResolve,
navigate,
revalidate,
enableClientRouter,
Expand Down Expand Up @@ -857,6 +859,49 @@ test('runWithTransition: with an async view transition, the commit promise resol
}
});

/* ====================================================================
* applyStreamedResolve: dev-warn on a dropped boundary (#1051)
* ==================================================================== */

test('applyStreamedResolve: warns once in dev when the boundary is absent, silent otherwise (#1051)', () => {
const origWarn = console.warn;
const warnings = [];
console.warn = (...a) => { warnings.push(a.join(' ')); };
const origNodeEnv = process.env.NODE_ENV;
const drops = () => warnings.filter((w) => /dropped a streamed Suspense resolve/.test(w)).length;
try {
document.body.innerHTML = ''; // no #s1 boundary present -> the resolve drops

// dev: a dropped resolve warns exactly once, naming the id.
process.env.NODE_ENV = 'development';
_resetWarnOnce(); warnings.length = 0;
_applyStreamedResolve('s1', '<div>x</div>');
assert.equal(drops(), 1, 'warns once on a dropped boundary in dev');
assert.ok(warnings.some((w) => w.includes('"s1"')), 'the warning names the dropped boundary id');
_applyStreamedResolve('s1', '<div>x</div>');
assert.equal(drops(), 1, 'fire-once per id: a second drop of the same id does not warn again');

// a SUCCESSFUL resolve never warns.
_resetWarnOnce(); warnings.length = 0;
document.body.innerHTML = '<div id="s2">SKELETON</div>';
_applyStreamedResolve('s2', '<div id="done">DONE</div>');
assert.equal(drops(), 0, 'a resolved boundary emits no drop warning');
assert.ok(document.getElementById('done'), 'and it applied the content');

// production: suppressed even on a drop.
_resetWarnOnce(); warnings.length = 0;
process.env.NODE_ENV = 'production';
document.body.innerHTML = '';
_applyStreamedResolve('s3', '<div>y</div>');
assert.equal(drops(), 0, 'no drop warning in production');
} finally {
console.warn = origWarn;
if (origNodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = origNodeEnv;
_resetWarnOnce();
document.body.innerHTML = '';
}
});

test('addNewHeadElements: dynamically-created scripts get the meta csp-nonce, not the source page\'s per-request nonce', () => {
// Set up the meta tag the server emits for the original page load.
document.head.innerHTML = '<meta name="csp-nonce" content="original-page-nonce">';
Expand Down Expand Up @@ -959,6 +1004,41 @@ test('mergeHead: applies meta csp-nonce to created scripts (replaces source nonc
'mergeHead must apply the meta nonce, not the source-page nonce');
});

test('mergeHead: keeps the ORIGINAL csp-nonce meta when the incoming nonce differs (#1050)', () => {
// A real full-body response carries a DIFFERENT per-request nonce. The browser
// enforces CSP against the original page-load nonce, so mergeHead must NOT
// replace the live meta, or getCspNonce() would then hand out a nonce the
// active policy rejects and later nonce-stamped scripts/preloads get blocked.
document.head.innerHTML = '<title>T</title><meta name="csp-nonce" content="ORIGINAL">';
const before = document.head.querySelector('meta[name="csp-nonce"]');
const newHead = document.createElement('head');
newHead.innerHTML =
'<title>T</title><meta name="csp-nonce" content="INCOMING">' +
'<script src="/after.js" nonce="INCOMING"></script>';
_merge(newHead);
const nonce = document.head.querySelectorAll('meta[name="csp-nonce"]');
assert.equal(nonce.length, 1, 'exactly one csp-nonce meta (not duplicated)');
assert.strictEqual(nonce[0], before, 'the original meta element is kept, not replaced');
assert.equal(nonce[0].getAttribute('content'), 'ORIGINAL', 'the original nonce content is preserved');
// A script created by the merge is stamped with the ORIGINAL nonce, not the incoming one.
assert.equal(document.head.querySelector('script[src="/after.js"]').getAttribute('nonce'), 'ORIGINAL',
'created scripts get the original page-load nonce');
});

test('mergeHead: keeps the csp-nonce meta when the incoming head omits it (#1050)', () => {
Comment thread
vivek7405 marked this conversation as resolved.
// A reduced / partial incoming head that carries no csp-nonce must not cause
// the removal loop to drop the live original (it is "absent from newSet").
document.head.innerHTML = '<title>T</title><meta name="csp-nonce" content="ORIGINAL">';
const before = document.head.querySelector('meta[name="csp-nonce"]');
const newHead = document.createElement('head');
newHead.innerHTML = '<title>T</title>'; // no csp-nonce declared
_merge(newHead);
const nonce = document.head.querySelectorAll('meta[name="csp-nonce"]');
assert.equal(nonce.length, 1, 'the original csp-nonce meta survives a head that omits it');
assert.strictEqual(nonce[0], before, 'same element, not re-created');
assert.equal(nonce[0].getAttribute('content'), 'ORIGINAL');
});

test('addNewHeadElements + mergeHead: nonce-only diff on <link> tags does not duplicate preloads', () => {
// Browsers gate cross-origin modulepreload by script-src nonce, so
// preload links also carry per-request nonces after the recent CSP
Expand Down