Skip to content
Moku LabsDocs
@moku-labs/web pages

spa

Default. The client runtime: island hydration, intercepted navigation, head sync and a progress bar. Inert on Node.

spa.mdllms.txt

What it does

Isomorphic default. spa makes the static site live in the browser. It mounts islands onto the server-rendered HTML, intercepts in-app links, swaps one page region without a full reload, syncs the head and shows a progress bar. On Node it does nothing, so the same app definition works in the build.

It is the one default plugin that owns a browser resource, so it is the one with onStart and onStop.

How it works

While createApp runs, spa builds a single kernel, validates its config, registers config.islands and records the current URL. It looks for the optional data plugin with ctx.has("data").

app.start() boots the kernel in the browser: it attaches the navigation listeners and mounts every [data-island] element. An island outside the swap region is persistent; one inside it is created and destroyed with each page. app.stop() unmounts every island and detaches the listeners.

A navigation takes the first path that works:

PathUsed whenWhat happens
Datarouter.mode() is not "ssg" and data is composed, or the route is client-only in spa moderouter.match → data.at(path) → the route's render into the swap region → islands re-mounted → title set
HTML-over-fetchthe data path is not available or failsfetch the page, replace swapSelector, copy the head
Full loadthe fetch failslocation.href

The Navigation API is used where the browser has it, the History API otherwise. Clicks with a modifier key, target="_blank", other origins and links to static files are left to the browser. Scroll positions are saved per page in sessionStorage and restored on back and forward. The progress bar waits 150 ms before it shows.

Config

pluginConfigs.spa. Every field is optional.

OptionTypeDefaultDescription
swapSelectorstring"main > section"The region replaced on navigation. Must be a valid CSS selector.
viewTransitionsboolean | TransitionModefalsetrue is "crossfade", false is "none". Modes: "none", "crossfade", "slide", "morph". A route's .transition() overrides it.
scrollRestoration"top" | "preserve""top"Scroll after a forward navigation. A route's .scroll() overrides it. Back and forward always restore.
progressBarbooleantrueThe top progress bar.
islandsIslandDef[][]Islands registered while createApp runs.

API

Mounted at app.spa, reachable with ctx.require(spaPlugin). Every method is a no-op before app.start() and on Node.

MethodSignatureDescription
register(island: IslandDef) => voidRegisters an island. A second island with the same name replaces the first and logs spa:island-collision.
navigate(path: string, options?: { scroll?, replace? }) => voidNavigates through the swap pipeline. replace: true replaces the history entry.
hardNavigate(url: string) => voidA real page load, for a boundary the SPA cannot swap: another layout, the auth split.
current() => stringThe current pathname + search. "" on Node.
island<T>(name: string) => T | undefinedAnother island's api, or undefined.

The module-scope navigate(path, options?) and hardNavigate(url) from @moku-labs/web/browser call the same kernel, for code with no app or island context.

Events

Emits

EventPayloadWhen
spa:navigate{ from: string; to: string }A navigation was intercepted and starts.
spa:navigated{ url: string }The swap finished and the new URL is active.
spa:island-mount{ name: string; el: Element }An island attached to an element.
spa:island-unmount{ name: string; el: Element }An island detached from an element.

Listens to

None.

Depends on

  • router: match, mode, toUrl.
  • head: composeTitle for the data path.
  • data, optional: at(path) for the data path.

Example

src/client.tsx
import { createApp, createIsland, dataPlugin, defineRoutes, route } from "@moku-labs/web/browser";

const routes = defineRoutes({
  home: route("/").render(() => <h1>Home</h1>),
  board: route("/board/{id}/")
    .render(ctx => <h1>Board {ctx.params.id}</h1>)
    .transition("slide")
});

const counter = createIsland("counter", {
  state: () => ({ count: 0 }),
  render: state => <button type="button">{state.count}</button>,
  events: { "click button": ctx => ctx.set(previous => ({ count: previous.count + 1 })) }
});

const app = createApp({
  plugins: [dataPlugin],
  config: { mode: "hybrid" },
  pluginConfigs: {
    site: { name: "Boards", url: "https://boards.dev", author: "Ada", description: "Kanban." },
    router: { routes },
    spa: {
      swapSelector: "main > section",
      viewTransitions: "crossfade",
      scrollRestoration: "top",
      islands: [counter]
    }
  }
});

await app.start();
app.spa.navigate("/board/42/");
console.log(app.spa.current());
  1. Must exist in every page's layout.
  2. Mounts islands and starts intercepting links. Does nothing on Node.
  3. Slides, because the board route says so.
  • One app per page. The teardown handles live at module scope. Two apps in one page share them.
  • swapSelector must match your layout. A selector that matches nothing makes every navigation a full load. A syntax error throws [web] spa.swapSelector is not a valid CSS selector when the browser app is created; Node has no DOM to check it.
  • The layout is not re-applied. On navigation only the swap region changes. .layout() runs at build only.
  • load does not run on the client. The data path uses the JSON the build saved. A client-only route in spa mode renders with ctx.data = {}.
  • Use @moku-labs/web/browser for the client. Its import graph has no Node module.