Menu
Public repository / 8b66e18ce8cb51ca3e4124f89fd1429a1d4792f3
AkurAI-Mail / src/framework/vendor/islands.js
Latest change 3af23b0e937520000e1aa43c7e2eb9de2675abd1 - OLI-248: webmail list and reader as framework islands on a signals store by Ólafur Búi Ólafsson
// @ts-nocheck
// Vendored from AkurAI-Framework site/frontend/islands.js @ d7fe19b (see docs/framework-migration.md, gap G2). Do not edit here.
// islands.js — a tiny SSR islands / hydration runtime for AkurAI/Framework.
//
// Native ES module. No bundler, no transpiler, no dependencies — it runs as-is
// in any modern browser, and builds directly on `signals.js`.
//
// The idea: the server already renders the full page (the SSR engine fills the
// HTML against backend/page.json). Most of that page is static and should never
// be touched by JS. Only small, explicitly-marked regions — "islands" — need to
// become interactive. This runtime hydrates ONLY those regions, in place,
// reusing the server-rendered nodes. No virtual DOM, no re-render, no flicker.
//
// Public API (also exposed as `window.Islands`):
// registerIsland(name, setupFn)
// Register an island component. `setupFn(el, initialState)` runs once per
// matching `[data-island="name"]` element after hydration. It receives the
// server-rendered root element and the parsed initial state, and wires
// signals/effects to the existing DOM in place. Whatever the setup returns
// is ignored; mutate `el` directly (or use signals.js directives on it).
// hydrate(root?)
// Scan `root` (default: document) for `[data-island]`, look up each in the
// registry, and hydrate it with its parsed initial state. Idempotent —
// an already-hydrated island is skipped.
//
// Reading initial state from the server markup (no flash of changed content):
// The signals start at exactly the value the server printed. Two sources, in
// priority order, are read off each island root:
// 1. A child <script type="application/json" data-island-state>{…}</script>
// (best for rich/nested state — it isn't an HTML attribute, so no escaping
// headaches).
// 2. A `data-state='{…}'` attribute on the root (same convention signals.js
// already uses).
// Whichever is found is JSON-parsed and handed to setupFn as `initialState`.
//
// Difference from signals.js auto-init: signals.js scans `[data-scope]` and wires
// declarative directives generically. Islands are *named components* — you write
// the wiring once in JS via registerIsland, and the server decides which islands
// exist and what state they start with. The two compose freely: an island's
// setupFn may call signals.js `signal`/`effect` itself.
import { signal, effect } from "./signals.js";
// ── registry ──────────────────────────────────────────────────────────────────
const registry = new Map();
// Register an island component under `name`. Re-registering replaces it (handy
// during live editing); a warning fires so accidental clobbers are visible.
function registerIsland(name, setupFn) {
if (typeof name !== "string" || !name) {
console.warn("[islands] registerIsland: name must be a non-empty string");
return;
}
if (typeof setupFn !== "function") {
console.warn(`[islands] registerIsland("${name}"): setupFn must be a function`);
return;
}
if (registry.has(name)) {
console.warn(`[islands] re-registering island "${name}"`);
}
registry.set(name, setupFn);
}
// ── initial-state parsing (pure, unit-testable in isolation) ───────────────────
// Read the initial state a server printed for an island root. Pure with respect
// to the DOM it's handed: same markup in → same object out, no side effects.
// Returns {} when nothing parseable is present so setupFn always gets an object.
function readInitialState(root) {
// 1. <script type="application/json" data-island-state> child (own island only)
const script = findStateScript(root);
if (script) {
const parsed = tryParse(script.textContent, "data-island-state script");
if (parsed !== undefined) return parsed;
}
// 2. data-state attribute
const attr = root.getAttribute && root.getAttribute("data-state");
if (attr && attr.trim().startsWith("{")) {
const parsed = tryParse(attr, "data-state attribute");
if (parsed !== undefined) return parsed;
}
return {};
}
// Find the nearest <script data-island-state> that belongs to THIS island root,
// not to a nested island deeper in the tree.
function findStateScript(root) {
if (!root.querySelectorAll) return null;
for (const script of root.querySelectorAll(
'script[type="application/json"][data-island-state]'
)) {
if (closestIsland(script) === root) return script;
}
return null;
}
// Walk up from `el` to the first ancestor-or-self carrying [data-island].
function closestIsland(el) {
if (el.closest) return el.closest("[data-island]");
let node = el;
while (node) {
if (node.getAttribute && node.getAttribute("data-island") != null) return node;
node = node.parentNode;
}
return null;
}
function tryParse(text, label) {
if (!text || !text.trim()) return undefined;
try {
return JSON.parse(text);
} catch (err) {
console.warn(`[islands] invalid JSON in ${label}:`, err);
return undefined;
}
}
// ── hydration ──────────────────────────────────────────────────────────────────
// Hydrate every [data-island] under `root` (default: whole document). Reuses the
// server-rendered nodes — never replaces or re-renders them. Idempotent.
function hydrate(root) {
const scope = root || document;
const islands = scope.querySelectorAll("[data-island]");
let count = 0;
islands.forEach((el) => {
if (el.__islandHydrated) return; // already done — no double-wiring
const name = el.getAttribute("data-island");
if (!name) return;
const setupFn = registry.get(name);
if (!setupFn) {
console.warn(`[islands] no island registered for "${name}" — leaving SSR markup as-is`);
return;
}
el.__islandHydrated = true;
try {
setupFn(el, readInitialState(el));
count += 1;
console.log("[islands] hydrated", name);
} catch (err) {
el.__islandHydrated = false; // allow a retry after a fix
console.warn(`[islands] island "${name}" setup failed:`, err);
}
});
return count;
}
// ── auto-init ──────────────────────────────────────────────────────────────────
//
// app.js is a deferred module, so by the time *this* module evaluates the DOM is
// usually already parsed and `DOMContentLoaded` may have fired. Guard on
// readyState rather than relying on a bare listener that could never run.
function autoHydrate() {
// Hydrate now if there are registered islands; otherwise wait a tick so
// inline <script type="module"> registrations below us get a chance to run.
hydrate(document);
}
if (typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", autoHydrate);
} else {
// DOM is ready. Defer to a microtask so sibling inline modules that register
// islands have run before the first scan.
queueMicrotask(autoHydrate);
}
}
// One namespaced global; nothing else leaks. `signal`/`effect` are re-exported so
// island setup functions can `import { signal, effect } from "/islands.js"`.
const Islands = { registerIsland, hydrate, readInitialState, signal, effect, version: "1.0.0" };
if (typeof window !== "undefined") window.Islands = Islands;
export { registerIsland, hydrate, readInitialState, signal, effect };
export default Islands;