Skip to content

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:

ts
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.

html
<% 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:

StrategyWhen it starts
loadAs soon as the loader runs; the default
visibleWhen it scrolls into view
idleWhen the browser is idle
interactionOn 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
ts
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:

ts
const enhanced = createEnhance({ script: "/native/enhance.js" });
html
<% 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
ts
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:

ts
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
mjs
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.

Small core. Build your way.