diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 868ee7b1..1ddd6255 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,3 +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"; diff --git a/packages/react/src/island-runtime.ts b/packages/react/src/island-runtime.ts index f9bb96c0..99964990 100644 --- a/packages/react/src/island-runtime.ts +++ b/packages/react/src/island-runtime.ts @@ -100,3 +100,16 @@ export function unmountIslands(root: ParentNode): void { roots.delete(element); } } + +/** + * Dev-only: dispose and re-create island roots 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. + */ +export async function remountIslands(root: ParentNode, options: MountOptions): Promise { + unmountIslands(root); + await mountIslands(root, options); +} diff --git a/packages/react/test/island-hmr.test.ts b/packages/react/test/island-hmr.test.ts new file mode 100644 index 00000000..37c02bf8 --- /dev/null +++ b/packages/react/test/island-hmr.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; +import { islandRootCount, mountIslands, remountIslands, unmountIslands } from "../src/island-runtime.ts"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function host(window: Window): ParentNode { + return window.document.body as unknown as ParentNode; +} + +function domWith(html: string) { + const window = new Window(); + window.document.body.innerHTML = html; + (globalThis as any).window = window; + (globalThis as any).document = window.document; + return window; +} + +afterEach(() => { + const doc = (globalThis as any).document; + if (!doc) return; + act(() => { + unmountIslands(doc); + }); +}); + +const marker = + `
`; + +test("remount replaces island output without leaking roots", async () => { + const window = domWith(marker); + + const first = async () => ({ default: () => createElement("span", null, "v1") }); + const second = async () => ({ default: () => createElement("span", null, "v2") }); + + await act(async () => { + await mountIslands(host(window), { loader: first }); + }); + expect(window.document.body.textContent).toContain("v1"); + expect(islandRootCount()).toBe(1); + + await act(async () => { + await remountIslands(host(window), { loader: second }); + }); + expect(window.document.body.textContent).toContain("v2"); + expect(window.document.body.textContent).not.toContain("v1"); + expect(islandRootCount()).toBe(1); +}); + +test("repeated remounts stay at one root", async () => { + const window = domWith(marker); + const loader = async () => ({ default: () => createElement("span", null, "x") }); + + await act(async () => { + await mountIslands(host(window), { loader }); + }); + + for (let i = 0; i < 4; i += 1) { + await act(async () => { + await remountIslands(host(window), { loader }); + }); + } + + expect(islandRootCount()).toBe(1); +});