From 4dd7681bf71ea98843b3774fb9b58d8fce75f0b5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 18:15:21 +0530 Subject: [PATCH] fix(dev): repair the HMR client and keep islands alive across updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/public-api-0.8.json | 2 + packages/dev-server/src/runtime.ts | 38 +++++++++++-- .../dev-server/test/hmr-client-syntax.test.ts | 18 ++++++ packages/dev-server/test/hmr.test.ts | 3 +- packages/react/src/browser.ts | 8 ++- packages/react/src/index.ts | 2 +- packages/react/src/island-runtime.ts | 55 ++++++++++++++++--- packages/react/src/runtime-source.ts | 5 ++ packages/react/test/island-hmr.test.ts | 22 ++++++++ packages/react/test/runtime-source.test.ts | 8 +++ 10 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 packages/dev-server/test/hmr-client-syntax.test.ts diff --git a/docs/public-api-0.8.json b/docs/public-api-0.8.json index 77ca4cc2..e15bf813 100644 --- a/docs/public-api-0.8.json +++ b/docs/public-api-0.8.json @@ -2284,6 +2284,7 @@ "StoreResolver", "createSelectorCache", "createSnapshotCache", + "discardDetachedRoots", "islandRootCount", "mountIslands", "remountIslands", @@ -2294,6 +2295,7 @@ ], "./browser": [ "MountOptions", + "discardDetachedRoots", "islandRootCount", "mountIslands", "remountIslands", diff --git a/packages/dev-server/src/runtime.ts b/packages/dev-server/src/runtime.ts index 8c226b11..22bfcf18 100644 --- a/packages/dev-server/src/runtime.ts +++ b/packages/dev-server/src/runtime.ts @@ -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). diff --git a/packages/dev-server/test/hmr-client-syntax.test.ts b/packages/dev-server/test/hmr-client-syntax.test.ts new file mode 100644 index 00000000..b8d2fc63 --- /dev/null +++ b/packages/dev-server/test/hmr-client-syntax.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { HMR_CLIENT_JS } from "../src/runtime.ts"; + +test("the HMR client script is syntactically valid JavaScript", () => { + // HMR_CLIENT_JS is a TypeScript template literal, so an escape like \t or \n + // written unescaped is expanded by TypeScript into a real control character. + // Inside a regex literal that produces a raw newline, which is a syntax error + // that silently kills the whole HMR client in the browser. + expect(() => new Function(HMR_CLIENT_JS)).not.toThrow(); +}); + +test("the HMR client contains no raw control characters inside regex literals", () => { + const regexLiterals = HMR_CLIENT_JS.match(/\/(?![/*])(?:\.|\[[^\]]*\]|[^/\n\r])+\//g) ?? []; + expect(regexLiterals.length).toBeGreaterThan(0); + for (const literal of regexLiterals) { + expect(literal).not.toMatch(/[\n\r\t]/); + } +}); diff --git a/packages/dev-server/test/hmr.test.ts b/packages/dev-server/test/hmr.test.ts index 0133d5ac..f8dcff18 100644 --- a/packages/dev-server/test/hmr.test.ts +++ b/packages/dev-server/test/hmr.test.ts @@ -14,5 +14,6 @@ test("HMR replaces hydrated components when their server signature changes", () expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-wrn-behavior")'); expect(HMR_CLIENT_JS).toContain('from.getAttribute("data-scope")'); expect(HMR_CLIENT_JS).toContain("window.__wrnexusDisposeBehaviors(from)"); - expect(HMR_CLIENT_JS).toContain("from.replaceWith(to.cloneNode(true))"); + // Cloned nodes are re-stamped with the live document nonce before insertion. + expect(HMR_CLIENT_JS).toContain("from.replaceWith(adoptNonce(to.cloneNode(true)))"); }); diff --git a/packages/react/src/browser.ts b/packages/react/src/browser.ts index c4af83bf..0c3b2b70 100644 --- a/packages/react/src/browser.ts +++ b/packages/react/src/browser.ts @@ -3,6 +3,12 @@ * `/__wrnexus/island/runtime.js` and imported by the bootstrap only when a * `data-wrn-island` marker is present. */ -export { islandRootCount, mountIslands, remountIslands, unmountIslands } from "./island-runtime.ts"; +export { + discardDetachedRoots, + islandRootCount, + mountIslands, + remountIslands, + unmountIslands, +} from "./island-runtime.ts"; export type { MountOptions } from "./island-runtime.ts"; export { setStoreResolver, useWrnActions, useWrnStore } from "./store-bridge.ts"; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 1ddd6255..0ce5a667 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,4 +6,4 @@ export { IslandErrorBoundary } from "./error-boundary.tsx"; export type { IslandErrorBoundaryProps } from "./error-boundary.tsx"; export { islandRootCount, mountIslands, unmountIslands } from "./island-runtime.ts"; export type { MountOptions } from "./island-runtime.ts"; -export { remountIslands } from "./island-runtime.ts"; +export { discardDetachedRoots, remountIslands } from "./island-runtime.ts"; diff --git a/packages/react/src/island-runtime.ts b/packages/react/src/island-runtime.ts index 99964990..753c20c9 100644 --- a/packages/react/src/island-runtime.ts +++ b/packages/react/src/island-runtime.ts @@ -5,6 +5,14 @@ import { IslandErrorBoundary } from "./error-boundary.tsx"; export interface MountOptions { loader: (name: string) => Promise<{ default: ComponentType }>; development?: boolean; + /** + * Re-render islands that are already mounted instead of skipping them. + * + * Used by HMR: the container element usually survives the morph, and React + * refuses a second `createRoot` on the same container, so the existing root + * has to be re-rendered rather than replaced. + */ + remount?: boolean; } const roots = new Map(); @@ -43,7 +51,7 @@ function whenReady(element: Element, strategy: string): Promise { } async function mountOne(element: Element, options: MountOptions): Promise { - if (roots.has(element)) return; + if (roots.has(element) && !options.remount) return; const name = element.getAttribute("data-wrn-island"); if (!name) return; @@ -53,7 +61,7 @@ async function mountOne(element: Element, options: MountOptions): Promise // Re-check: an await point means a concurrent mount may have claimed this // element while the strategy was resolving. - if (roots.has(element)) return; + if (roots.has(element) && !options.remount) return; let Component: ComponentType; try { @@ -63,9 +71,11 @@ async function mountOne(element: Element, options: MountOptions): Promise return; } - if (roots.has(element)) return; + if (roots.has(element) && !options.remount) return; - const root = createRoot(element); + // Reuse an existing root: React rejects a second createRoot on the same + // container, and HMR keeps the container across a morph. + const root = roots.get(element) ?? createRoot(element); roots.set(element, root); root.render( createElement( @@ -102,14 +112,41 @@ export function unmountIslands(root: ParentNode): void { } /** - * Dev-only: dispose and re-create island roots after a source change. + * Dev-only: re-render islands after a source change. * * Island state resets by design — Fast Refresh needs a Babel/SWC transform plus - * a runtime and is out of scope. `unmountIslands` clears each element from the - * root registry, so the following `mountIslands` is not short-circuited by the - * already-mounted guard. + * a runtime and is out of scope. */ export async function remountIslands(root: ParentNode, options: MountOptions): Promise { - unmountIslands(root); + // Every mounted container is swapped for a bare clone before remounting. + // + // Re-rendering the existing root is not enough: HMR wipes the container's + // children externally, and React — whose virtual tree is unchanged — treats + // the re-render as a no-op and leaves the island blank. Unmounting instead + // throws asynchronously, because the DOM it wants to remove is already gone. + // A fresh container sidesteps both, and React accepts createRoot on a node it + // has never seen. + for (const [element] of [...roots]) { + if (element !== (root as unknown as Element) && !(root as unknown as Node).contains(element)) { + continue; + } + roots.delete(element); + if (element.isConnected) element.replaceWith(element.cloneNode(false)); + } + discardDetachedRoots(); await mountIslands(root, options); } + +/** + * Forgets roots whose container left the document. + * + * HMR morphs server markup over the mounted island, so React's DOM is already + * gone by the time we get here; calling unmount then throws asynchronously with + * "The node to be removed is not a child of this node". Navigation still uses + * `unmountIslands`, where the DOM is intact and cleanup must actually run. + */ +export function discardDetachedRoots(): void { + for (const element of [...roots.keys()]) { + if (!element.isConnected) roots.delete(element); + } +} diff --git a/packages/react/src/runtime-source.ts b/packages/react/src/runtime-source.ts index ab9a50b9..177f1763 100644 --- a/packages/react/src/runtime-source.ts +++ b/packages/react/src/runtime-source.ts @@ -25,6 +25,11 @@ export function getIslandRuntime(development = false): string { development: ${development} }); }; + // HMR morphs the server placeholder back over the mounted island, which + // discards the React tree. Remount once the DOM has settled. + window.addEventListener("wrnexus:hmr-updated", function () { + window.__wrnexusRemountIslands(); + }); }).catch(function (error) { console.error("[wrnexus] failed to load the island runtime", error); }); diff --git a/packages/react/test/island-hmr.test.ts b/packages/react/test/island-hmr.test.ts index 56cee2b3..dff62401 100644 --- a/packages/react/test/island-hmr.test.ts +++ b/packages/react/test/island-hmr.test.ts @@ -70,3 +70,25 @@ test("repeated remounts stay at one root", async () => { expect(islandRootCount()).toBe(1); }); + +test("remount re-renders in place instead of creating a second root", async () => { + const window = domWith(marker); + const loader = async () => ({ default: () => createElement("span", null, "v1") }); + + await act(async () => { + await mountIslands(host(window), { loader }); + }); + expect(islandRootCount()).toBe(1); + + // Simulate HMR morphing server markup back over the mounted island: React's + // rendered DOM is gone, so unmounting it would throw. + const el = window.document.querySelector("[data-wrn-island]")!; + el.innerHTML = ""; + + await act(async () => { + await remountIslands(host(window), { loader }); + }); + + expect(islandRootCount()).toBe(1); + expect(window.document.body.textContent).toContain("v1"); +}); diff --git a/packages/react/test/runtime-source.test.ts b/packages/react/test/runtime-source.test.ts index a34a5030..74b96e6d 100644 --- a/packages/react/test/runtime-source.test.ts +++ b/packages/react/test/runtime-source.test.ts @@ -23,3 +23,11 @@ test("threads the development flag into the mount options", () => { test("encodes the island name before using it as a URL path segment", () => { expect(getIslandRuntime(false)).toContain("encodeURIComponent"); }); + +test("the runtime remounts islands after an HMR update", () => { + // HMR morphs the server placeholder over the mounted island, discarding the + // React tree; without this the island silently disappears on every edit. + const source = getIslandRuntime(true); + expect(source).toContain("wrnexus:hmr-updated"); + expect(source).toContain("__wrnexusRemountIslands"); +});