// Build one island, import the chunk the way the hydration runtime does, and run its `mount` over // the micro-DOM. The BUILDER arrives as a parameter — `@ultimat3/cli` owns `buildIslands` and both // packages are tier 5, so importing it here would be the reverse of the one declared `cli → testing` // edge. A structural seam keeps the direction honest and survives the bundler changing underneath. import { basename, join } from 'node:path'; import { islandMountMissing, islandNotBuilt } from './errors'; import { onFileBoundary } from './file-boundary'; import { createIslandDocument, FakeElement, handlerFor, parseHtml } from './island-dom'; import { installGlobals } from './island-globals'; import type { ResizeInput } from './island-observers'; import { deliverResize } from './island-observers'; import { createScratchDir, removeScratchDir } from './island-scratch'; /** The two fields a mounted island needs from a chunk. Anything else a bundler grows — a CSS * artifact, a source map, a dev/production flag — is invisible here on purpose. */ export interface IslandChunkLike { /** App-root-relative POSIX path of the client entry the chunk was built from. */ readonly file: string; /** * The built JavaScript. It may import the bundle's shared chunks by a relative `./`, which * is why `shared` below is written beside it before it is imported. */ readonly code: string; } /** A module two or more islands import, served beside them under its own name. */ export interface SharedChunkLike { readonly url: string; readonly code: string; } export interface IslandBundleLike { readonly chunks: readonly IslandChunkLike[]; /** Optional, so a builder of your own that splits nothing is still assignable. */ readonly shared?: readonly SharedChunkLike[]; } /** * `buildIslands` from `@ultimat3/cli` satisfies this structurally — no import, no new tier edge. * * The second parameter is OPTIONAL on both sides, `As of 2026-08-27`, which is what keeps that true: * a builder of your own written as `(root) => …` is still assignable, and `buildIslands`' own * `options` argument is already optional. Widening it is what lets `mountIsland` name the one island * it is about to mount — see the call site for what building all of them was costing. */ export type IslandBuilder = ( root: string, options?: { readonly only?: string }, ) => Promise; export interface MountIslandOptions { readonly build: IslandBuilder; /** The app root the builder globs from. */ readonly root: string; /** Which island, app-root-relative. Always named: an app with two islands has no default. */ readonly file: string; /** The JSON the document would carry in `data-x-props`. */ readonly props?: unknown; /** The markup the server rendered inside the island's wrapper, which `mount` must replace. */ readonly shell?: string; /** Anything the micro-DOM does not supply — `fetch` above all. Merged over the DOM globals. */ readonly globals?: Readonly>; /** * The host element's box BEFORE `mount` runs — the one element that exists then, laid out by * the server-rendered page around it. Everything the island creates starts at 0 and is sized * after mount through `resize`, which is the order a browser's `ResizeObserver` delivers in. */ readonly size?: ResizeInput; } /** * `Disposable`: dispose calls the disposer the island's `mount` returned (Solid's `render` answers * one — `return render(…)`), then hands the process its globals back. Both, in that order. */ export interface MountedIsland extends Disposable { /** The chunk's source, for asserting on what did or did not reach the browser. */ readonly code: string; /** The host element `mount` was given. */ readonly el: FakeElement; /** The `` this mount wrote to — `documentElement.dataset` is a real island's only door out. */ readonly documentElement: FakeElement; find(selector: string): FakeElement | null; all(selector: string): readonly FakeElement[]; /** `textContent` of the first match, `''` when there is none — an assertion reads a string. */ text(selector: string): string; /** * Drive a handler the compiled island attached, delegated (`$$change`) or not. Answers whether * one RAN: a selector that matches nothing and an island that attached no handler are the same * silence otherwise, and the second is a bug while the first is a typo in the test. */ fire( target: string | FakeElement | null | undefined, type: string, event?: Readonly>, ): boolean; /** * The browser's layout, in the test's hand: write the box onto the element and deliver an entry * to every `ResizeObserver` the island has watching THAT element. Answers whether one ran, for * `fire`'s reason — an element nothing observes and an island that never observed are otherwise * the same silence, and the second is the bug. */ resize(target: string | FakeElement | null | undefined, size: ResizeInput): boolean; /** Move the element's scroll offset and run its `scroll` listener. Answers whether one ran. */ scroll( target: string | FakeElement | null | undefined, to: { top?: number; left?: number }, ): boolean; /** Whether any live `ResizeObserver` is watching the element — `false` once it disconnected. */ observing(target: string | FakeElement | null | undefined): boolean; } /** * Module-private, and its `mount` returns `unknown` rather than `void` — the shipped hydration * runtime chains `import(e).then((m) => m.mount(el, props))` (`packages/render/src/hydrate.ts`), * so a browser AWAITS whatever `mount` answers before it marks the element mounted. An island that * opens a queue or a socket first is therefore an ordinary island (`like.island.tsx` is `async`), * and a fixture typed `=> void` could only ever call it and walk away. Widening, not narrowing: an * island module is matched structurally off an `unknown` import, so nothing implements this type. * * What `mount` answers, once awaited, is the island's DISPOSER when it is a function — the one * way an island hands back what it started, and Solid's `render` already returns exactly that, so * `return render(…)` is the whole of an island's side. The fixture calls it on dispose. */ interface IslandEntry { readonly mount: (el: unknown, props: unknown) => unknown; } /** The island's disposer, when `mount` answered one — anything else is an island with nothing to stop. */ const disposerOf = (returned: unknown): (() => void) | undefined => typeof returned === 'function' ? (returned as () => void) : undefined; /** `unknown` + a check, not a cast: a chunk whose `mount` was renamed is a real authoring mistake * and `entry.mount is not a function` names neither the file nor the export it wanted. */ function entryOf(module: unknown, file: string): IslandEntry { const mount = (module as Record | null)?.['mount']; if (typeof mount !== 'function') { throw islandMountMissing(file, Object.keys((module as object | null) ?? {})); } return { mount: mount as IslandEntry['mount'] }; } /** * A temp FILE named by the chunk's own hash, NOT a `data:` URL: `bun test --coverage` panics on * `import()` of a `data:` module past ~4 kB (Bun 1.4.0) and an island chunk is 12-55 kB. The file * sits in the mount's OWN directory (`island-scratch.ts`), so a mount is a fresh module instance. */ const modulePathFor = (dir: string, code: string): string => join(dir, `${Bun.SHA256.hash(code, 'hex').slice(0, 16)}.mjs`); /** * The island fixture. Everything it installs is process-global, so the result is `Disposable` and * the idiom is `using mounted = await mountIsland(…)` — a mount left installed hands a fake * `document` to every later FILE in the run. * * Dispose is two things in one order: the island's own disposer first, then the globals back. * Restoring the globals was the whole of it until 2026-09-07, so an island whose `mount` started * an interval kept ticking after the DOM was gone — measured as `document is not defined` thrown * into whichever later test was running, and as one file's fetch stub receiving POSTs from another * file's island. The disposer runs while the fake `document` is still installed, because what it * clears (a listener on `document`, an interval whose callback reads it) was made against that one. */ /** * Every mount not yet disposed — from the moment it installs its globals, not from when `mount` * answers. A worker runs many files in one global (22.7), so a mount a file forgot to `using` is * disposed at the file boundary (`file-boundary.ts`) instead of handing its fake * `window`/`document` to every later file. Registered BEFORE the awaits, because a mount that * never settles — a test that timed out inside an island's `mount` — was otherwise nobody's: its * globals stayed installed and its scratch directory stayed in the temp dir, one per timeout. */ const liveMounts = new Set<() => void>(); /** Dispose every mount still installed, newest first. Returns how many there were. */ export function disposeLiveIslands(): number { const pending = [...liveMounts].reverse(); for (const dispose of pending) { try { dispose(); } catch { // The island's own disposer threw; its globals were still restored in `dispose`'s finally. } } return pending.length; } // Registered here, not by each preload: the repo's own preload and an app's both install the leak // guard that runs the boundary, and a mount a file forgot is one only this module can see. onFileBoundary(disposeLiveIslands); export async function mountIsland(options: MountIslandOptions): Promise { // `only`, because this fixture has always KNOWN which island it wants and asked for all of them // anyway — `island-bundle.ts` added the option for exactly this caller ("a test that mounts a // single island otherwise pays every OTHER island's Babel pass and `Bun.build` on every file, and // the reference app is the one that feels it") and nothing ever passed it. // // It is also the difference between green and red on Bun 1.3.14, which is how it was found: // building the reference app's four islands in a test process that has already imported // `@ultimat3/realtime` at module scope makes `Bun.build` throw `Unexpected reading file: // packages/realtime/src/index.ts`. One island does not, and neither does `x build` — measured, // `x build --target static` is green on 1.3.14 — so this is a bundler defect confined to // `bun test`, fixed in 1.4.0, and asking for the island we actually want walks around it while // being the faster and more honest call regardless. A builder that ignores the argument still // works: the `find` below is unchanged. const bundle = await options.build(options.root, { only: options.file }); const chunk = bundle.chunks.find((each) => each.file === options.file); if (chunk === undefined) { throw islandNotBuilt( options.file, options.root, bundle.chunks.map((each) => each.file), ); } const { documentElement, globals, resizeObservers } = createIslandDocument(); const restore = installGlobals({ ...globals, ...options.globals }); let dir: string | undefined; let unmount: (() => void) | undefined; let disposed = false; const dispose = (): void => { // Once: `using` and an `afterAll` that also disposes by hand are both real, and Solid's own // disposer is idempotent while an island's `clearInterval` wrapper need not be. if (disposed) return; disposed = true; liveMounts.delete(dispose); try { unmount?.(); } finally { // Whatever the disposer did — including throw, which is the test's to see — the process // gets its globals back, or the fake `document` reaches every later file in the run. restore(); if (dir !== undefined) removeScratchDir(dir); } }; liveMounts.add(dispose); try { dir = createScratchDir(); const scratch = dir; // Beside the entry, under the name its bytes import them by: the same relative resolution a // browser does under `/islands/`. Named by their content-addressed URL, so a rewrite is the same // bytes at the same name. for (const shared of bundle.shared ?? []) { await Bun.write(join(scratch, basename(shared.url)), shared.code); } const path = modulePathFor(scratch, chunk.code); await Bun.write(path, chunk.code); // By `file:` URL, never the bare path: Bun on macOS caches a directory's entries on its first // resolution, and a bare-path import of a file written there afterwards is "Cannot find module" // (#704, Bun 1.4.0 arm64). The per-mount directory already avoids it; the URL form holds even // if a later change ever imports twice from one directory. const entry = entryOf(await import(Bun.pathToFileURL(path).href), chunk.file); const el = new FakeElement('div'); // Before the shell and before `mount`: nothing observes the host yet, so this is a write and // never a notification. if (options.size !== undefined) deliverResize(resizeObservers, el, options.size); if (options.shell !== undefined) { for (const child of [...parseHtml(options.shell).children]) el.appendChild(child); } // AWAITED, because the runtime this fixture stands in for awaits it. Left unawaited, an async // island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out // — so it failed with `document is not defined` inside whichever later test happened to be // running, with no thread back here. const stop = disposerOf(await entry.mount(el, options.props)); if (disposed) { // The boundary took this mount back while `mount` was still running — its test is long // over. What it started is stopped now, and nobody is left to see a throw from it. try { stop?.(); } catch { // Its `document` is already gone; the stop was best-effort for that reason. } } else unmount = stop; const resolve = (target: string | FakeElement | null | undefined): FakeElement | null => typeof target === 'string' ? el.querySelector(target) : (target ?? null); return { code: chunk.code, el, documentElement, find: (selector) => el.querySelector(selector), all: (selector) => el.querySelectorAll(selector), text: (selector) => el.querySelector(selector)?.textContent ?? '', fire: (target, type, event) => { const node = resolve(target); const handler = node === null ? undefined : handlerFor(node, type); if (handler === undefined) return false; handler({ currentTarget: node, target: node, ...event }); return true; }, resize: (target, size) => { const node = resolve(target); return node === null ? false : deliverResize(resizeObservers, node, size); }, scroll: (target, to) => { const node = resolve(target); if (node === null || handlerFor(node, 'scroll') === undefined) return false; node.scrollTo(to); return true; }, observing: (target) => { const node = resolve(target); return node !== null && [...resizeObservers].some((each) => each.targets.has(node)); }, [Symbol.dispose]: dispose, }; } catch (error) { // A mount that throws restores the process before it rethrows: the alternative leaves every // later file in the run holding a fake `document`, which fails somewhere with no thread back. dispose(); throw error; } }