Two faults found by driving the island demo in a real browser. Both were
silent: the markup, every asset, and all 48 island tests were correct
either way.
An island renders nothing until it mounts, so its placeholder is
zero-height, and IntersectionObserver does not treat a zero-area target
consistently -- client:visible islands mounted on one load and not the
next. Visibility for those is now decided from the element's own rect,
driven by scroll and resize; a placeholder with real size still uses the
observer. The strategy had no test at all, which is why this shipped.
After an island source edit the browser kept running the old code. The
rebuild worked and the file was refetched, but the loader imports a URL
that does not change, and the browser caches modules by URL. Remounts now
carry a generation the dev loader folds into the request.
Verified in the browser: mounts with start={3} as a number, clicks reach
React (3 -> 5), and an edit to Counter.tsx now shows the new text and
stays interactive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
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 =
|
|
`<div data-wrn-island="Chart" data-wrn-island-strategy="only"` +
|
|
` data-wrn-island-props='{}'></div>`;
|
|
|
|
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);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
test("a remount asks the loader for a newer generation than the mount did", async () => {
|
|
// The rebuilt island keeps its URL. Without a changing generation the dev
|
|
// loader re-imports the cached module and the page keeps the old code --
|
|
// silently, because the island still mounts and still works.
|
|
const window = domWith(marker);
|
|
const generations: number[] = [];
|
|
const loader = async (_name: string, generation: number) => {
|
|
generations.push(generation);
|
|
return { default: () => createElement("span", null, `gen ${generation}`) };
|
|
};
|
|
|
|
await act(async () => {
|
|
await mountIslands(host(window), { loader });
|
|
});
|
|
await act(async () => {
|
|
await remountIslands(host(window), { loader });
|
|
});
|
|
|
|
expect(generations.length).toBe(2);
|
|
expect(generations[1]).toBeGreaterThan(generations[0]!);
|
|
expect(window.document.body.textContent).toContain(`gen ${generations[1]}`);
|
|
});
|