Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd7681bf7 |
@@ -2284,6 +2284,7 @@
|
||||
"StoreResolver",
|
||||
"createSelectorCache",
|
||||
"createSnapshotCache",
|
||||
"discardDetachedRoots",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
@@ -2294,6 +2295,7 @@
|
||||
],
|
||||
"./browser": [
|
||||
"MountOptions",
|
||||
"discardDetachedRoots",
|
||||
"islandRootCount",
|
||||
"mountIslands",
|
||||
"remountIslands",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]/);
|
||||
}
|
||||
});
|
||||
@@ -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)))");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user