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>
This commit is contained in:
@@ -3,7 +3,14 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { IslandErrorBoundary } from "./error-boundary.tsx";
|
||||
|
||||
export interface MountOptions {
|
||||
loader: (name: string) => Promise<{ default: ComponentType<any> }>;
|
||||
/**
|
||||
* Resolve an island module by name.
|
||||
*
|
||||
* `generation` counts remounts. A rebuilt island keeps its URL and the
|
||||
* browser caches a module by URL, so a dev loader must fold this into the
|
||||
* request or the page keeps running the code it first imported.
|
||||
*/
|
||||
loader: (name: string, generation: number) => Promise<{ default: ComponentType<any> }>;
|
||||
development?: boolean;
|
||||
/**
|
||||
* Re-render islands that are already mounted instead of skipping them.
|
||||
@@ -16,6 +23,7 @@ export interface MountOptions {
|
||||
}
|
||||
|
||||
const roots = new Map<Element, Root>();
|
||||
let generation = 0;
|
||||
|
||||
export function islandRootCount(): number {
|
||||
return roots.size;
|
||||
@@ -32,8 +40,40 @@ function readProps(element: Element): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function whenReady(element: Element, strategy: string): Promise<void> {
|
||||
if (strategy === "visible" && typeof IntersectionObserver !== "undefined") {
|
||||
function rectOf(element: Element): DOMRect | null {
|
||||
const measure = (element as HTMLElement).getBoundingClientRect;
|
||||
return typeof measure === "function" ? (element as HTMLElement).getBoundingClientRect() : null;
|
||||
}
|
||||
|
||||
function inViewport(element: Element): boolean {
|
||||
const rect = rectOf(element);
|
||||
if (!rect) return false;
|
||||
|
||||
const height = window.innerHeight || document.documentElement?.clientHeight || 0;
|
||||
const width = window.innerWidth || document.documentElement?.clientWidth || 0;
|
||||
|
||||
return rect.top <= height && rect.bottom >= 0 && rect.left <= width && rect.right >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve once the island's placeholder has come into view.
|
||||
*
|
||||
* An island renders nothing until it mounts, so its placeholder is usually
|
||||
* zero-height -- and IntersectionObserver does not treat a zero-area target
|
||||
* consistently. When it declines to report one, the island never mounts at
|
||||
* all, which is silent: the markup and every asset are present and correct.
|
||||
* Those are driven from the element's own rect instead; a placeholder with
|
||||
* real size (an SSR fallback, or a reserved min-height) still uses the
|
||||
* observer, which is cheaper and needs no scroll listener.
|
||||
*/
|
||||
function whenVisible(element: Element): Promise<void> {
|
||||
if (typeof window === "undefined") return Promise.resolve();
|
||||
if (inViewport(element)) return Promise.resolve();
|
||||
|
||||
const rect = rectOf(element);
|
||||
const hasArea = !!rect && rect.width > 0 && rect.height > 0;
|
||||
|
||||
if (hasArea && typeof IntersectionObserver !== "undefined") {
|
||||
return new Promise((resolve) => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
@@ -44,6 +84,26 @@ function whenReady(element: Element, strategy: string): Promise<void> {
|
||||
observer.observe(element);
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (!inViewport(element)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("scroll", check, true);
|
||||
window.removeEventListener("resize", check);
|
||||
};
|
||||
|
||||
// Capture phase so a scrolling container, not just the page, wakes it.
|
||||
window.addEventListener("scroll", check, true);
|
||||
window.addEventListener("resize", check);
|
||||
});
|
||||
}
|
||||
|
||||
function whenReady(element: Element, strategy: string): Promise<void> {
|
||||
if (strategy === "visible") return whenVisible(element);
|
||||
if (strategy === "idle" && typeof requestIdleCallback !== "undefined") {
|
||||
return new Promise((resolve) => requestIdleCallback(() => resolve()));
|
||||
}
|
||||
@@ -65,7 +125,7 @@ async function mountOne(element: Element, options: MountOptions): Promise<void>
|
||||
|
||||
let Component: ComponentType<any>;
|
||||
try {
|
||||
Component = (await options.loader(name)).default;
|
||||
Component = (await options.loader(name, generation)).default;
|
||||
} catch (error) {
|
||||
console.error(`[wrnexus] failed to load island bundle for '${name}'`, error);
|
||||
return;
|
||||
@@ -118,6 +178,10 @@ export function unmountIslands(root: ParentNode): void {
|
||||
* a runtime and is out of scope.
|
||||
*/
|
||||
export async function remountIslands(root: ParentNode, options: MountOptions): Promise<void> {
|
||||
// A remount only happens after a rebuild, so the modules on the other side
|
||||
// of the loader have changed.
|
||||
generation++;
|
||||
|
||||
// Every mounted container is swapped for a bare clone before remounting.
|
||||
//
|
||||
// Re-rendering the existing root is not enough: HMR wipes the container's
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
export function getIslandRuntime(development = false): string {
|
||||
return `
|
||||
(function () {
|
||||
function loader(name) {
|
||||
return import("/__wrnexus/island/" + encodeURIComponent(name) + ".js");
|
||||
function loader(name, generation) {
|
||||
var url = "/__wrnexus/island/" + encodeURIComponent(name) + ".js";
|
||||
// A rebuilt island keeps its URL, and the browser caches modules by URL,
|
||||
// so a remount has to ask for a URL it has not imported before.
|
||||
return import(generation ? url + "?v=" + generation : url);
|
||||
}
|
||||
|
||||
function boot() {
|
||||
|
||||
Reference in New Issue
Block a user