Browser islands
An island adds interaction to part of an HTML page: a counter, an editor, or an enhanced form. The rest of the page stays ordinary HTML. No client router needed.
Use @portablesheep/brevity-islands for plain TypeScript components. Add @portablesheep/brevity-islands-svelte only if you want Svelte.
Write a component
An island module exports a mount function. It receives an element, props, and an abort signal; it can return cleanup:
import { defineIsland } from "@portablesheep/brevity-islands/client";
export default defineIsland<{ text: string }>((element, props, context) => {
element.dataset.hydrated = "true";
const output = document.createElement("output");
output.textContent = props.text;
element.append(output);
const button = element.querySelector("button");
button?.addEventListener(
"click",
() => {
output.textContent = "clicked";
},
{ signal: context.signal },
);
return () => {
output.remove();
delete element.dataset.hydrated;
};
});Keep a usable fallback on the page until the component is ready. If JavaScript fails or is disabled, that content is what the reader gets.
Render it on the server
Configure createIslands({ manifest, loader }), then call island(name, props, { hydrate, fallback }) from a template. Props are safely serialized into an attribute, not executable inline JavaScript.
<% for (const hydrate of ["load", "idle", "interaction", "visible"]) { %>
<section id="<%= hydrate %>"<% if (hydrate === "visible") { %> style="margin-top:200vh"<% } %>>
<%= it.island("counter", { text: hydrate }, {
hydrate, fallback: await it.partial("fallback", { hydrate }),
}) %>
</section>
<% } %>
<%= it.island("broken", {}, { fallback: await it.partial("fallback", { hydrate: "broken" }) }) %>Choose when the component mounts:
| Strategy | When it starts |
|---|---|
load | As soon as the loader runs; the default |
visible | When it scrolls into view |
idle | When the browser is idle |
interaction | On pointer, focus, or keyboard interaction |
If visibility observation is unavailable, visible mounts immediately; idle falls back to a 200 ms timer. The first interaction isn't cancelled or replayed. A native link or form can still work before mounting finishes. Use load if the first click needs the component already running.
Server setup and asset map
import { fileURLToPath } from "node:url";
import type { App, Response } from "@portablesheep/brevity";
import { createViews } from "@portablesheep/brevity-views";
import { createEnhance, createIslands, islandManifest } from "@portablesheep/brevity-islands";
import { createFormState, html, staticAssets } from "@portablesheep/brevity-web";
const island = createIslands({ manifest: "/native/manifest.json", loader: "/assets/loader.js" });
// #region enhance-setup
const enhanced = createEnhance({ script: "/native/enhance.js" });
// #endregion enhance-setup
const views = createViews({
root: fileURLToPath(new URL("./views", import.meta.url)),
helpers: { island },
layout: "layout",
});
export function nativeRoutes(app: App) {
app.use((req, res, next) => {
res.header("content-security-policy", "script-src 'self'; object-src 'none'; base-uri 'none'");
return next(req, res);
});
app.use(
staticAssets({
"/native/counter.js": { file: fileURLToPath(new URL("./dist/counter.js", import.meta.url)) },
"/native/form.js": { file: fileURLToPath(new URL("./dist/form.js", import.meta.url)) },
"/native/client.js": {
file: fileURLToPath(import.meta.resolve("@portablesheep/brevity-islands/client")),
},
"/native/enhance.js": {
file: fileURLToPath(import.meta.resolve("@portablesheep/brevity-islands/enhance.js")),
},
}),
);
app.router.get("/native/manifest.json", (_req, res) =>
res.json(
islandManifest({
counter: "/native/counter.js",
form: "/native/form.js",
broken: "/native/missing.js",
}),
),
);
app.router.get("/native", (_req, res) => views.render(res, "strategies"));
const renderForm = async (res: Response, value = "", status = 200) => {
const form = createFormState({
values: { text: value },
labels: { text: "Note" },
errors:
status === 422
? { text: ["Write a note."] }
: status === 409
? { text: ["Conflict: review your note."] }
: {},
});
const fallback = await views.renderToString(
"form",
{ form, saved: status === 200 && !!value },
{ layout: false },
);
await views.render(res, "page", { content: island("form", {}, { fallback }) }, { status });
};
app.router.get("/native/form", (_req, res) => renderForm(res));
app.router.post("/native/form", async (req, res) => {
const value = req.form?.get("text") ?? "";
await new Promise((resolve) => setTimeout(resolve, 150));
if (value === "redirect") {
res.redirect("/native/form", 303);
return;
}
await renderForm(res, value, !value.trim() ? 422 : value === "conflict" ? 409 : 200);
});
// Directive only: no manifest or island module. A nested counter island restarts after replacement.
// #region enhance-route
const renderEnhanced = async (res: Response, value = "", status = 200) => {
const form = createFormState({
values: { text: value },
labels: { text: "Note" },
errors: status === 422 ? { text: ["Write a note."] } : {},
});
const content = await views.renderToString(
"enhanced",
{ form, saved: status === 200 && !!value },
{ layout: false },
);
await views.render(res, "page", { content: enhanced("enhanced-note", content) }, { status });
};
app.router.get("/native/enhanced", (_req, res) => renderEnhanced(res));
app.router.post("/native/enhanced", async (req, res) => {
const value = req.form?.get("text") ?? "";
await renderEnhanced(res, value, value.trim() ? 200 : 422);
});
// #endregion enhance-route
app.router.get("/native/hostile", (_req, res) =>
views.render(res, "page", {
content: island(
"counter",
{ text: '</script><img src=x onerror="window.pwned=true">' },
{
fallback: html`<p>Safe fallback</p>`,
},
),
}),
);
}The server maps component names to module URLs with islandManifest and serves explicit assets with staticAssets. Include every module/chunk in that map; Brevity doesn't scan a directory or compile browser code.
Enhance a form without a component
For a form-only enhancement, createEnhance needs no island module or manifest:
const enhanced = createEnhance({ script: "/native/enhance.js" });<% const field = it.form.field("text"); %>
<%= it.form.errorSummary() %>
<% if (it.saved) { %><p>Saved</p><% } %>
<form method="post" action="/native/enhanced">
<label for="<%= field.id %>">Note</label>
<input id="<%= field.id %>" name="text" value="<%= field.value %>"
aria-invalid="<%= field.invalid %>" aria-describedby="<%= field.describedBy %>">
<%= it.form.fieldErrors("text") %>
<button type="submit">Save</button>
</form>
<%= it.island("counter", { text: it.saved ? "after save" : "nested" }, { fallback: await it.partial("fallback", { hydrate: "nested" }) }) %>Render with the same target ID for GET and validation-error responses:
Route example
const renderEnhanced = async (res: Response, value = "", status = 200) => {
const form = createFormState({
values: { text: value },
labels: { text: "Note" },
errors: status === 422 ? { text: ["Write a note."] } : {},
});
const content = await views.renderToString(
"enhanced",
{ form, saved: status === 200 && !!value },
{ layout: false },
);
await views.render(res, "page", { content: enhanced("enhanced-note", content) }, { status });
};
app.router.get("/native/enhanced", (_req, res) => renderEnhanced(res));
app.router.post("/native/enhanced", async (req, res) => {
const value = req.form?.get("text") ?? "";
await renderEnhanced(res, value, value.trim() ? 200 : 422);
});Serve @portablesheep/brevity-islands/enhance.js at the configured script URL. The form still submits normally without JavaScript. With JavaScript, it updates the target with server-rendered HTML and keeps validation feedback on the page.
Control form enhancement yourself
Use enhanceForm inside a component when you need custom behavior:
import { defineIsland } from "@portablesheep/brevity-islands/client";
import { enhanceForm } from "@portablesheep/brevity-islands/forms";
export default defineIsland((element, _props, context) => {
const target = element.querySelector<HTMLElement>("#enhanced-form");
const form = target?.querySelector("form");
if (!target || !form) throw new Error("Missing native form");
const cleanup = enhanceForm(form, { target, signal: context.signal });
element.dataset.ready = "true";
return () => {
cleanup();
delete element.dataset.ready;
};
});The target must contain the form and have a stable ID in the response HTML. Same-origin POSTs are enhanced; unsupported forms fall back to native submission. Validation responses (422/409) replace the target and focus the error. A redirect navigates to its same-origin destination. Server validation, CSRF checks, and access control still belong in the app.
Events, errors, and cleanup
Pending submission disables submit buttons and sets aria-busy; failures restore them and retain input. Network/protocol failures log and emit brevity:form-error; show a visible message for that event. Successful replacement emits brevity:form-response with { status }. onResponse receives a cloned response.
The helper emits brevity:before-replace and brevity:replaced. An initial-page islands loader uses them to dispose and restart nested islands. Scripts in replacement HTML don't execute, so keep enhancement code outside the target.
Submitter method/action/encoding overrides, cross-origin actions, unsupported file encoding, and unsupported methods/targets use native submission. Controls named action, method, target, or enctype don't break attribute handling. Disposing aborts in-flight work.
A mount may be async and return cleanup when ready. Use ctx.signal for abortable listeners, validate untrusted props, and avoid changing the fallback before a mount can succeed. Islands mount components; they don't provide server rendering.
startIslands(root?) scans once and returns a disposer. For custom navigation, dispose before removing old content and start on the new page. There's no global mutation observer or automatic client routing. Mount errors log and emit a bubbling brevity:island-error without stopping other islands.
Build and serve assets
Compile TypeScript and resolve browser imports with your build tool. This example uses esbuild; plain JavaScript modules don't need a bundler:
Native build script
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
await build({
absWorkingDir: fileURLToPath(new URL("./", import.meta.url)),
entryPoints: ["counter.ts", "form.ts"],
outdir: "dist",
bundle: true,
format: "esm",
target: "es2022",
minify: true,
});Manifest URLs are canonical same-origin paths. Imports resolve relative to the manifest URL. Use no-cache for fixed filenames and immutable caching only for content-hashed assets.
For Svelte, follow the Svelte recipe. With multiple Vite app entries, set build.rollupOptions.preserveEntrySignatures: "strict" so Rollup keeps each island's default export. Convert Vite's manifest into your island URL map and serve its chunks/CSS too.
Content security policy
The example uses external scripts with script-src 'self'; object-src 'none'; base-uri 'none'. Allow manifest fetches in connect-src. For nonce-based CSP, pass the same nonce to createIslands and your policy; permit dynamic imports as needed. Hashes/SRI are deployment-owned. Don't put secrets in props.
Measured payload
The native loader is 1,998 minified bytes / 941 gzip bytes. The standalone form enhancement script is 2,943 minified bytes / 1,332 gzip bytes. Those numbers exclude components, props, and Svelte.
Measured with esbuild 0.28.1, target ES2022, Node 22.21.1, and Node's default gzip. Run node scripts/build-islands.mjs and node scripts/measure-islands.mjs to repeat. Core, web, and native islands have no external runtime dependencies; views adds Eta. See size and performance for comparisons.