DOCUMENTATION Docs Powered by Fuzor Docs

Islands

An island is a small interactive region inside an otherwise compiled HTML page. Each <fuzor-island> owns its own DOM host and receives serializable props.

<fuzor-island src="counter" props='{"initial":1}' client:load></fuzor-island>

The island module exports mount(host, props) and may return a cleanup function. client:load mounts immediately, while client:idle or client:visible can defer work, and client:none keeps only the build-time HTML.

Mixed frameworks

The root example app demonstrates vanilla JavaScript, React, Solid, and Vue islands together. Each framework owns only its island host. React and Solid JSX files use separate compiler filters, preventing one JSX transform from processing the other's files.

Compiler-rendered previews

The compiler renders React, Solid, and Vue islands during the build when their props are known. Their initial HTML is included in the SPA template, MPA file, and server document. The browser later hydrates that existing markup within the island host. Vanilla islands can also supply a build-time render(props) function and attach behavior to the existing DOM when activated.

An island without a build-time renderer keeps any author-provided fallback HTML in its host. Request-dependent props, asynchronous data, and guaranteed zero layout shift for arbitrary content are not supported yet.

Build-only components

client:none renders a React, Solid or Vue component (or a module's render()) at build time and keeps only its HTML: the page gets no island host, no props and no component JavaScript. Use it to write static parts of a page with a component library you already use.

<fuzor-island src="profile-card" props='{"name":"Ada","role":"analyst"}' client:none></fuzor-island>

Removing a component's JavaScript is only safe when its HTML is everything it does, so the build checks the component and every project module it imports, and fails with a list of what it found:

ConstructExamples
StateuseState, useReducer, createSignal, createStore, ref, reactive
Effects and listenersuseEffect, createEffect, onMount, watch, onMounted, addEventListener, v-model
Event handlersonClick props, @click, Solid on* handlers
Runtime contextuseContext, inject, getOwner
Browser globalswindow, document, localStorage, matchMedia, observers
Non-deterministic valuesMath.random(), Date.now(), new Date(), crypto.randomUUID()
Asynchronous renderingasync/await, use, lazy, Suspense, createResource, dynamic import()
Request datarouteData, fetch

Names are matched wherever they appear, so an unrelated function called useState is reported too; keep a hydrating directive for such components. A vanilla module used with client:none must export render() and no mount(). Build-only components cannot have an error template. Other framework integrations keep working through render(); their components are not analyzed.

Loading indicators

An island without a build-time preview can show <fuzor-loading> while its code downloads:

<fuzor-island src="chart" client:visible>
  <fuzor-loading delay="200" min="500" class="spinner">Loading chart…</fuzor-loading>
</fuzor-island>

The indicator is output hidden with role="status". It appears only if loading takes longer than delay milliseconds (default 200), so fast connections never see it flash, and once visible it stays for at least min milliseconds (default 500), so it does not blink; mounting waits for that minimum. Without JavaScript it never appears. It must be a direct child of the island. Islands with a preview (framework components, render() or client:none) reject it, because the preview is already visible; style fuzor-island[data-fuzor-state="pending"] for those instead. Pages with server data can use the same element outside islands. It works in Markdown on its own lines.

Styles

Import CSS from an island module (import "./card.css"), or use <style> in a Vue component. Vite extracts it into stylesheets, and the build links the stylesheets of every island on a page into that page's <head>, after the shell's own styles. Previews and client:none components are styled at first paint, even before (or without) their JavaScript. An SPA document links the stylesheets of islands on all its pages. In fuzor dev, styles arrive with each island's module.

Island CSS is ordinary global CSS: a rule in card.css applies to every matching element on the page, and it stays loaded after SPA navigation leaves the page. Scope styles that must not leak with CSS modules (card.module.css) or Vue's <style scoped>, which also works for client:none components.

Hydration mismatches

A preview is rendered at build time, so a component that renders differently in the browser (dates, random values, typeof window checks) does not match its preview. Fuzor keeps the island working: React and Vue patch the difference, and Solid, which cannot, renders the island fresh. Each mismatch is logged as a warning naming the island and dispatched as a bubbling fuzor:hydration-mismatch event with { island, framework, error }:

document.addEventListener("fuzor:hydration-mismatch", event => reportToMonitoring(event.detail));

Vue only reports mismatches in development builds. Move values that differ into an effect that runs after mounting.

Writing an island adapter

React, Solid and Vue have built-in adapters (fuzor/islands/react, …/solid, …/vue). Any other framework works through the same contract that those adapters use. An island module exports:

ExportRunsContract
mount(host, props)In the browser when the island activatesRenders into or hydrates host. May return a cleanup function, or a promise of one; cleanup may be asynchronous.
render(props, { id })Once per island at build time, in Vite's SSR environmentOptional. Returns the preview HTML string, or a promise of it. id is unique per island on the page, for frameworks that generate element ids. Any other return value is a build error.

When render produced the preview, the host has a data-fuzor-prerendered attribute, so mount knows whether to hydrate. This Preact island is tested in Chromium, Firefox and WebKit:

// islands/counter.ts
import { h, hydrate, render as renderClient } from "preact";
import { useState } from "preact/hooks";

function Counter(props: { label: string }) {
  const [count, setCount] = useState(0);
  return h("button", { onClick: () => setCount(count + 1) }, `${props.label}: ${count}`);
}

export async function render(props: { label: string }) {
  if (!import.meta.env.SSR) throw new Error("render() runs during the build");
  const { renderToString } = await import("preact-render-to-string");  // kept out of browser bundles
  return renderToString(h(Counter, props));
}

export function mount(host: HTMLElement, props: { label: string }) {
  if (host.hasAttribute("data-fuzor-prerendered")) hydrate(h(Counter, props), host);
  else renderClient(h(Counter, props), host);
  return () => renderClient(null, host);
}

Guard server-only imports with import.meta.env.SSR so the renderer is not shipped to the browser. Props must be JSON values because they are written into the page.

File suffixes and versions

Fuzor itself does not choose a JSX transform. The convention used by the examples is .react.tsx for React and .solid.tsx for Solid, with the Vite plugins limited to their suffix (react({ include: /\.react\.[jt]sx$/ }), solid({ include: /\.solid\.[jt]sx$/ })); Vue files end in .vue. When the project has .react.jsx or .react.tsx islands, the build checks that the installed react and react-dom have the same version and stops with both versions otherwise, because a mismatch fails only at hydration time in the browser.

React islands are React components, not a React application: there is no React router, data loading or server components. Routing, data and server code belong to Fuzor.

Several versions of one framework

Pages can mix islands built with different major versions of React (or another JSX framework), for example while migrating. Install the extra version under npm aliases and name it in the plugin:

pnpm add react-18@npm:react@18.3.1 react-dom-18@npm:react-dom@18.3.1
// vite.config.ts
export default defineConfig({
  plugins: [
    react({ include: /\.react(?:18)?\.[jt]sx$/ }),
    fuzorPlugin({ runtimes: { react18: { react: "react-18", "react-dom": "react-dom-18" } } })
  ]
});

An island named counter.react18.tsx keeps importing react and fuzor/islands/react; Fuzor points it at the aliases:

  • its imports of react and react-dom, and the JSX runtime its compiler adds, use react-18;
  • project modules it imports (./Button.tsx) use the same version, and importing one module from islands of two versions fails the build;
  • imports inside react-dom-18 of react use react-18 too. Package managers do not do this: pnpm links an aliased react-dom@18 to the project's React 19, which fails at hydration;
  • each version gets its own copy of Fuzor's adapter and its own chunks, so nothing is shared between the versions in the browser;
  • previews render with the island's own react-dom-18/server (this needs Node 22.15 or newer).

Every extra version is downloaded in full by pages that use it: in the test app a React 18 island adds 45 KB gzip on top of React 19's 60 KB. Use it as a migration aid, not as a default. Vue islands cannot use runtimes, because Vue's compiler adds imports of vue that Fuzor cannot redirect.

Islands in Markdown

Put an island on its own line in a Markdown page; see Markdown.

Lifecycle and scheduling

Routes own their islands through Effect scopes. Navigation disposes active islands, observers and idle callbacks; an import finishing later cannot mount into the disposed route. Failed islands retain working siblings and show a local error message.

The host exposes data-fuzor-state as preview, pending, active, failed, retrying or disposed, with aria-busy while activation runs. Use these attributes for conservative loading styles without replacing a compiled preview.

client:interaction begins activation on pointer down or focus. A plain button click arriving before activation finishes is replayed once if the same button survives in the active host.

Text typed into a preview before its island activates is kept. Fuzor records changed input, textarea and select values and the focused field before mount, then writes them back to the matching fields afterwards and fires input and change events, so React, Vue and Solid state sees the typed value. Focus and the text selection are restored unless the user has moved focus elsewhere. Fields are matched by position within the host; a field the component no longer renders is skipped.

<fuzor-island src="controls/counter" client:interaction>
  <button type="button">Count: 0</button>
</fuzor-island>

Nested module folders use relative names: islands/controls/counter.ts becomes src="controls/counter". Each host accepts at most one activation directive. Unknown module names and conflicting directives are compilation errors. Islands inserted at runtime are checked too: a host with conflicting or unknown client:* directives fails activation through the island error boundary instead of guessing a schedule.

mount can return a promise, and its cleanup function can be asynchronous. Active cleanup is awaited before replacing route content. If a pending mount finishes after disposal, its returned cleanup is run immediately. Standalone mountIslands returns a cleanup function whose promise can be awaited. Cleanup failures are reported and do not prevent other islands from cleaning up.

Timeouts are opt-in. createRouter(outlet, { islandTimeouts: { mountTimeoutMs: 10000, cleanupTimeoutMs: 2000 } }), or the same fields in the mountIslands options, fail an activation that takes too long and stop waiting for a stuck cleanup so navigation can continue. JavaScript imports cannot be aborted: if a timed-out mount finishes later, its cleanup runs on retry or disposal.

Error recovery

The default local error UI includes a Try again button in SPA and MPA builds. Retry restores the initial preview and activates the island in a fresh Effect scope. Repeated retry requests share one attempt; a disposed island cannot retry. Working siblings remain usable.

To use your own error UI, point an island at a template anywhere on the page. Elements with data-fuzor-retry retry activation:

<template id="chart-error">
  <p role="alert">The chart could not be loaded.</p>
  <button type="button" data-fuzor-retry>Try again</button>
</template>
<fuzor-island src="chart" error="chart-error" client:visible></fuzor-island>

The template lives outside the island so that framework hydration never sees it. A missing template is a compilation error.

Standalone mountIslands can receive islandErrorHandler as its error handler to use this UI. Without an error handler it rejects activation failures and cleans up partially activated siblings. Its optional fourth argument accepts an AbortSignal for cancelling pending startup and the timeout fields above.

Route params and server data

Islands inside a dynamic route read its parameters with routeParams(host) from fuzor/runtime/islands. On pages with a server.ts, routeData(host) from fuzor/runtime/data resolves to the page's server data; see server data.

Built with Fuzor Back to top ↑
On this page Back to top ↑