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"); +});