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
+7 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+46 -9
View File
@@ -5,6 +5,14 @@ import { IslandErrorBoundary } from "./error-boundary.tsx";
export interface MountOptions {
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
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<Element, Root>();
@@ -43,7 +51,7 @@ function whenReady(element: Element, strategy: string): Promise<void> {
}
async function mountOne(element: Element, options: MountOptions): Promise<void> {
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<void>
// 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<any>;
try {
@@ -63,9 +71,11 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
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<void> {
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);
}
}
+5
View File
@@ -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);
});