Files
ClintchizandClaude Opus 5 e898929193
Quality / quality (ubuntu-latest) (push) Failing after 9m55s
Quality / quality (windows-latest) (push) Canceled after 0s
fix(react): mount visible islands and load rebuilt code after HMR
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>
2026-08-19 13:32:05 +05:30

219 lines
6.4 KiB
TypeScript

import { afterEach, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act, createElement } from "react";
import { islandRootCount, mountIslands, unmountIslands } from "../src/island-runtime.ts";
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// happy-dom's element types do not structurally match lib.dom's ParentNode;
// this cast is a test-environment concern, not a runtime one.
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;
}
const loader = async () => ({
default: (props: { title?: string }) => createElement("span", null, props.title ?? "none"),
});
afterEach(() => {
const doc = (globalThis as any).document;
if (!doc) return;
act(() => {
unmountIslands(doc);
});
});
function marker(props = "{}", strategy = "only") {
return (
`<div data-wrn-island="Chart" data-wrn-island-strategy="${strategy}"` +
` data-wrn-island-props='${props}'></div>`
);
}
test("mounts an island and passes deserialized props", async () => {
const window = domWith(marker('{"title":"Revenue"}'));
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(window.document.body.textContent).toContain("Revenue");
expect(islandRootCount()).toBe(1);
});
test("unmounts roots and leaves no leaked roots behind", async () => {
const window = domWith(marker('{"title":"A"}'));
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
act(() => {
unmountIslands(host(window));
});
expect(islandRootCount()).toBe(0);
});
test("repeated mount/unmount cycles do not accumulate roots", async () => {
const window = domWith(marker());
for (let i = 0; i < 5; i += 1) {
await act(async () => {
await mountIslands(host(window), { loader });
});
act(() => {
unmountIslands(host(window));
});
}
expect(islandRootCount()).toBe(0);
});
test("does nothing when no island markers are present", async () => {
const window = domWith(`<p>plain server html</p>`);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(0);
});
test("mounting twice does not create a second root for the same element", async () => {
const window = domWith(marker());
await act(async () => {
await mountIslands(host(window), { loader });
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
});
test("a failing bundle load leaves the placeholder and mounts no root", async () => {
const window = domWith(marker());
const original = console.error;
console.error = () => {};
await act(async () => {
await mountIslands(host(window), {
loader: async () => {
throw new Error("network down");
},
});
});
console.error = original;
expect(islandRootCount()).toBe(0);
expect(window.document.querySelector("[data-wrn-island]")).not.toBeNull();
});
test("malformed props JSON falls back to empty props instead of throwing", async () => {
const window = domWith(marker("not-json"));
const original = console.error;
console.error = () => {};
await act(async () => {
await mountIslands(host(window), { loader });
});
console.error = original;
expect(window.document.body.textContent).toContain("none");
expect(islandRootCount()).toBe(1);
});
/**
* Stand in for the browser's IntersectionObserver, reporting only targets that
* actually have area.
*
* That is the case the real one is inconsistent about: an island placeholder
* is empty until it mounts, so it is zero-height, and an engine that declines
* to report it leaves the island unmounted forever.
*/
function installAreaOnlyObserver(window: Window, onObserve?: () => void) {
const observed: Element[] = [];
(globalThis as any).IntersectionObserver = class {
constructor(private callback: (entries: { isIntersecting: boolean }[]) => void) {}
observe(element: Element) {
observed.push(element);
onObserve?.();
const rect = (element as unknown as HTMLElement).getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) this.callback([{ isIntersecting: true }]);
}
disconnect() {}
};
return observed;
}
/** Place the island marker at a given position with a given size. */
function positionIsland(window: Window, top: number, height: number) {
const element = window.document.querySelector("[data-wrn-island]") as unknown as HTMLElement;
element.getBoundingClientRect = () =>
({ top, bottom: top + height, left: 0, right: 800, width: 800, height }) as DOMRect;
return element;
}
test("mounts a visible island whose placeholder has no height", async () => {
const window = domWith(marker("{}", "visible"));
installAreaOnlyObserver(window);
positionIsland(window, 40, 0);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});
test("a visible island below the fold waits, then mounts once scrolled to", async () => {
const window = domWith(marker("{}", "visible"));
installAreaOnlyObserver(window);
positionIsland(window, 5000, 0);
let settled = false;
// Started outside act: it stays pending until the scroll, and the render it
// then performs is what act needs to wrap.
const mounting = mountIslands(host(window), { loader }).then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(settled).toBe(false);
expect(islandRootCount()).toBe(0);
positionIsland(window, 100, 0);
await act(async () => {
window.dispatchEvent(new window.Event("scroll"));
await mounting;
});
expect(settled).toBe(true);
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});
test("a placeholder with real size still goes through the observer", async () => {
const window = domWith(marker("{}", "visible"));
let observedCount = 0;
installAreaOnlyObserver(window, () => {
observedCount++;
});
positionIsland(window, 5000, 300);
await act(async () => {
await mountIslands(host(window), { loader });
});
expect(observedCount).toBe(1);
expect(islandRootCount()).toBe(1);
delete (globalThis as any).IntersectionObserver;
});