LangENKO

Island

The .island.tsx contract — the single signal Mandu uses to decide what ships to the browser, and the mismatch guards that keep hydration honest.

since v0.22
On this page

Island

The island is the unit of "stuff that needs JavaScript in the browser." Mandu does not ship a client bundle for a page unless a file with the right name says so. That is the whole trick.

This page covers the contract, how the FS scanner decides what becomes the clientModule, the one mismatch trap the framework guards against, and what an island actually costs.

The contract — filename + directive

An island is a file that satisfies both of these:

  1. Ends in .island.tsx (or .island.ts, .island.jsx, .island.js).
  2. Begins with the "use client" directive on its first non-whitespace line.
// src/client/widgets/counter/Counter.island.tsx
"use client";
import { useState } from "react";

export default function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

The filename alone is the scanner's signal — the FS scanner detects a file as type: "island" purely from the .island.* suffix (source: router/fs-scanner.ts:164, detectFileType). The "use client" directive is enforced separately by Guard, because without it React will not execute the module as a client component.

How the bundler picks the clientModule

When the scanner walks app/, each page route can have zero, one, or more client modules associated with it. The resolution order is precise:

1. Any *.island.tsx file co-located in the same directory as page.tsx
   → clientModule = that island file
   → (first island wins if multiple are present)

2. No co-located island, but page.tsx starts with "use client"
   → clientModule = the page itself

3. Neither → clientModule is undefined → no client bundle for this route

The relevant code lives in router/fs-scanner.ts:280-314:

if (islands?.[0]) {
  // priority 1: explicit island file
  clientModule = join(this.config.routesDir, islands[0].relativePath)...;
} else if (file.type === "page" && pageFileContent) {
  // priority 2: page itself has "use client"
  const hasUseClient = /^\s*["']use client["']/m.test(pageFileContent);
  if (hasUseClient) {
    clientModule = modulePath;
  }
}

This is why the filename is load-bearing. An .island.tsx next to a page is the way to say "this route is interactive." Adding "use client" to the page file itself also works but gives you less flexibility — you lose the ability to have a mostly-static page with an interactive strip.

The hydration-shell-mismatch guard

There is exactly one island anti-pattern the framework refuses to ship. It looks like this:

// app/dashboard/page.tsx  ← DON'T
import Dashboard from "./Dashboard.island";

export default function Page() {
  return (
    <>
      <h1>Dashboard</h1>
      {typeof Dashboard !== "undefined" && null}
    </>
  );
}

The intent — "render an SSR shell, delegate hydration to the island" — is wrong because the server emits the <h1> but nothing below, while the client hydrates a different tree. React will scream at runtime; Mandu screams at scan time instead.

The scanner detects this with a regex pair (source: router/fs-scanner.ts:347-364):

  1. Find an import whose source matches *.island.tsx?.
  2. Check whether the page content contains typeof <ImportedName> !== "undefined" && null.

If both match, the route is rejected with error type hydration_shell_mismatch_risk. The fix is to use a single render tree: either have the route entry directly export the island component, or render the island component normally and let it hydrate in place.

Hydration strategies

The island() helper in @mandujs/core (source: island/index.ts:23-28) accepts five strategies:

Strategy When to hydrate
"load" Immediately on page load
"idle" On requestIdleCallback (fallback: setTimeout(200))
"visible" When the island enters the viewport (IntersectionObserver)
"media" When a media query matches
"never" SSR output only — no JS on the client
import { island } from "@mandujs/core";

export default island("visible", function Heavy({ data }) {
  // ...
});

The runtime script attached to data-mandu-island elements dispatches on the data-hydrate attribute set by createIslandPlaceholder() (source: island/index.ts:269-280).

Bundle cost model

Every island contributes to the client payload for any route that includes it. The rough model:

page bundle = React runtime
            + every island imported (transitively)
            + anything those islands import from node_modules
  • The React runtime is amortized across all routes with any island.
  • Co-locate islands with the page that uses them — the scanner only looks in the page's directory, so an island in src/client/widgets/** must be imported by the page (which pulls it into that page's clientModule resolution only if it is the co-located file).
  • An island that ends up on only one page still ships its full dependency tree. Split large deps behind "idle" or "visible" strategies so the page renders fast even if the hydration is deferred.

Anti-patterns

Do not do this.

  1. Adding "use client" to page.tsx to "make it interactive." It works — the whole page becomes its own clientModule — but you lose the static shell advantage. Prefer a co-located .island.tsx instead.
  2. Using .island.tsx without "use client". The file will be scanned, registered, and then fail to execute at runtime because React will treat it as a server component. Guard flags this.
  3. Importing a server module (src/server/**) from inside an island. The module will try to bundle into the client chunk, likely fail, and in the lucky case where it does not fail, ship secrets to the browser. Guard rule: no-server-in-client.

🤖 Agent Prompt

🤖 Agent Prompt — Island
Apply the guidance from the Mandu docs page at https://mandujs.com/docs/architect/island to my project.

Summary of the page:
Islands are the opt-in client boundary. The .island.tsx suffix and 'use client' directive together form the contract. The FS scanner picks the first island co-located with a page as that route's clientModule; otherwise a page with 'use client' is its own clientModule. A known SSR-shell pattern triggers a hydration-shell-mismatch error at scan time.

Required invariants — must hold after your changes:
- Any file named *.island.tsx is a client-bundled module
- An island must start with the 'use client' directive
- For a page route, the co-located island (if any) becomes clientModule; otherwise a page with 'use client' is its own clientModule
- A page that imports an island and also renders {typeof X !== 'undefined' && null} against it is rejected at scan time as a hydration-shell-mismatch-risk

Then:
1. Make the change in my codebase consistent with the page.
2. Run `bun run guard` and `bun run check` to verify nothing
   in src/ or app/ breaks Mandu's invariants.
3. Show me the diff and any guard violations.

For Agents

AI hint

Islands are the opt-in client boundary. The .island.tsx suffix and 'use client' directive together form the contract. The FS scanner picks the first island co-located with a page as that route's clientModule; otherwise a page with 'use client' is its own clientModule. A known SSR-shell pattern triggers a hydration-shell-mismatch error at scan time.

Invariants
  • Any file named *.island.tsx is a client-bundled module
  • An island must start with the 'use client' directive
  • For a page route, the co-located island (if any) becomes clientModule; otherwise a page with 'use client' is its own clientModule
  • A page that imports an island and also renders {typeof X !== 'undefined' && null} against it is rejected at scan time as a hydration-shell-mismatch-risk
Guard scope
island