fix(dev): repair the HMR client and keep islands alive across updates

The HMR client script was dead in the browser. HMR_CLIENT_JS is a
TypeScript template literal, so the regex [ \t\r\n] inside it was expanded
into real control characters, producing a regex literal containing a raw
newline — a syntax error that took the whole script down with "Invalid
regular expression: missing /". It now uses \s, and a test asserts the
emitted client parses and holds no control characters inside regex
literals; that test fails if the bug is reintroduced.

HMR also corrupted CSP nonces. A document's nonce is fixed at load, but
morph copied attributes from freshly fetched HTML, overwriting the live
nonce with one the browser will not honour. syncAttrs now leaves nonce
alone, and nodes moved across are re-stamped with the live nonce.

Islands vanished on every HMR update: morph puts the server placeholder
back over the mounted island. The island runtime now remounts on
wrnexus:hmr-updated. Remounting swaps the container for a bare clone —
re-rendering the existing root is a no-op once HMR has wiped the DOM
externally, and unmounting throws asynchronously because the nodes React
wants to remove are already gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 18:15:21 +05:30
co-authored by Claude Opus 5
parent 843db2815f
commit 4dd7681bf7
10 changed files with 143 additions and 18 deletions
+32 -6
View File
@@ -677,7 +677,7 @@ export const HMR_CLIENT_JS = `
function (node) { return /^window[.]__wrnI18n=/.test(String(node.textContent || "").trim()); },
);
if (i18nScript) {
var i18nMatch = /^window[.]__wrnI18n=([^]*);[ \t\r\n]*$/.exec(String(i18nScript.textContent || "").trim());
var i18nMatch = /^window[.]__wrnI18n=([^]*);\\s*$/.exec(String(i18nScript.textContent || "").trim());
if (i18nMatch) {
try {
var incomingI18n = JSON.parse(i18nMatch[1]);
@@ -805,6 +805,30 @@ export const HMR_CLIENT_JS = `
// Preserve a hydrated subtree only while its server hydration signature and
// behavior are unchanged. Component edits must replace and re-hydrate the
// old subtree or HMR will keep stale markup indefinitely.
// HMR fetches fresh HTML whose inline scripts carry a NEW server nonce, but a
// document's CSP nonce is fixed at load and cannot be updated. Any node moved
// across therefore has to be re-stamped with the live document's nonce or the
// browser blocks it.
function adoptNonce(node) {
var nonce = currentDocumentNonce();
if (!nonce || !node || node.nodeType !== 1) return node;
var stamp = function (element) {
if (element.getAttribute("src")) return;
element.setAttribute("nonce", nonce);
try {
element.nonce = nonce;
} catch (error) {
// Read-only in some engines; the attribute above is what CSP checks.
}
};
if (node.nodeName === "SCRIPT" || node.nodeName === "STYLE") stamp(node);
if (node.querySelectorAll) {
var nested = node.querySelectorAll("script,style");
for (var i = 0; i < nested.length; i++) stamp(nested[i]);
}
return node;
}
function morph(from, to) {
if (from.__wrnexusHydrated) {
var sameHydration =
@@ -813,16 +837,16 @@ export const HMR_CLIENT_JS = `
from.getAttribute("data-scope") === to.getAttribute("data-scope");
if (sameHydration) return;
if (window.__wrnexusDisposeBehaviors) window.__wrnexusDisposeBehaviors(from);
from.replaceWith(to.cloneNode(true));
from.replaceWith(adoptNonce(to.cloneNode(true)));
return;
}
syncAttrs(from, to);
var fc = from.childNodes, tc = to.childNodes, i;
for (i = 0; i < tc.length; i++) {
var t = tc[i], f = fc[i];
if (!f) { from.appendChild(t.cloneNode(true)); continue; }
if (!f) { from.appendChild(adoptNonce(t.cloneNode(true))); continue; }
if (f.nodeType !== t.nodeType || (f.nodeType === 1 && f.nodeName !== t.nodeName)) {
from.replaceChild(t.cloneNode(true), f); continue;
from.replaceChild(adoptNonce(t.cloneNode(true)), f); continue;
}
if (f.nodeType === 3 || f.nodeType === 8) { if (f.nodeValue !== t.nodeValue) f.nodeValue = t.nodeValue; continue; }
if (f.nodeType === 1) morph(f, t);
@@ -831,8 +855,10 @@ export const HMR_CLIENT_JS = `
}
function syncAttrs(from, to) {
var ta = to.attributes, fa = from.attributes, i;
for (i = 0; i < ta.length; i++) if (from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
for (i = fa.length - 1; i >= 0; i--) if (!to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
// Never copy the incoming nonce: it belongs to the fetched document and
// would replace the live nonce this document's CSP actually allows.
for (i = 0; i < ta.length; i++) if (ta[i].name !== "nonce" && from.getAttribute(ta[i].name) !== ta[i].value) from.setAttribute(ta[i].name, ta[i].value);
for (i = fa.length - 1; i >= 0; i--) if (fa[i].name !== "nonce" && !to.hasAttribute(fa[i].name)) from.removeAttribute(fa[i].name);
}
// Exposed for tests; harmless (the client is injected only in dev).