Islands
use client の export を対象とする到達可能な Island 境界で、サーバー描画済み DOM をブラウザーに引き継ぎます。
zfb のページはデフォルトで静的 HTML にレンダリングされます。Islands(島) はその抜け道です。ブラウザに JavaScript を配信してクライアントでハイドレートする小さなコンポーネントで、ページの残りの部分はプレーンな HTML のままに保たれます。
メンタルモデルは単純です。ページの大部分は静的なドキュメントです。いくつかのインタラクティブな要素(カウンター、検索ボックス、テーマ切り替え)は、そのドキュメントに埋め込まれた島であり、単一の共有バンドルからブラウザでハイドレートされます。
島とは何か
.tsx ファイルの先頭に "use client" ディレクティブを追加します。
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export default function Counter() {
const count = signal(0);
return (
<button on:click={() => { count.value += 1; }}>
Count: {count}
</button>
);
}"use client" は、そのモジュールの export を island target として使えるようにします。関数がハイドレーション対象として登録されるのは、到達可能なページで SDK の <Island> 境界の唯一のコンポーネント子として使われる場合だけです。client module は別の場所で定義された関数を re-export できます。登録された関数が使う import や resource も、通常の bundle graph を通じてブラウザーに届きます。
境界ターゲットの検出と移行
到達可能な <Island> 境界の具体的なターゲットだけが公開マーカーを持ちます。クライアントモジュールから関数を export すること、大文字で始まる名前を付けること、JSX を返すことだけでは登録されません。未使用のヘルパーは、複数のモジュールで同じ名前のままにできます。名前を変更する必要はありません。
スキャナーは named/default import、ローカル alias、namespace member、明示的 re-export と star re-export、インストール済みパッケージをたどります。JSX の子と children 属性、Island({ children: h(Counter, props) }) のような直接呼び出し、SDK の h・jsx・jsxs・jsxDEV factory と alias 付きの packed 出力を認識します。固定ターゲットまたは子をそのまま転送する wrapper は、動作を静的に解決できれば利用できます。コンポーネント map では Fragment で囲んだ固定ターゲットの形を使います。bare return wrapper を opaque map に保存すると拒否されます。読み取り専用のパラメーターを持つファクトリでは、直接呼び出しが閉じた集合をなし、同一 module の const object literal からの安全な 1 段 spread を含む object literal を各呼び出しで渡し、すべてが同じ関数定義を選ぶと証明できれば、パラメーターのプロパティをターゲットにできます。wrapper 自身の名前は登録されません。registry に入るのは境界ターゲットだけです。実際に使われるターゲットの helper と import は bundle に残り、未使用 helper に一意な名前を付ける必要はありません。
スキャナーは plugin の setup が登録した edge も解決します。対象は完全一致する addAlias の書き換えと addVirtualModule の specifier です。同じ specifier に一致する tsconfig.json の compilerOptions.paths が解決できれば、その設定が優先されます。paths の候補がどれも解決できない場合は、完全一致の plugin alias、登録済み virtual module、続いて baseUrl と package の検索へ進みます。
virtual module から実ファイルを static import/re-export すると、スキャナーはそのファイルをたどります。"use client" を付ける module は実ファイルに置いてください。その module は別の実ファイルで定義された関数を re-export しても構いませんが、export した関数を到達可能な <Island> の唯一の子として渡す必要があります。ディレクティブだけでは target は登録されません。virtual source 内に直接書いた "use client" ディレクティブや import.meta.glob は警告が出てスキップされます。これらは実ファイルに置き、そのファイルを virtual module からたどれるようにしてください。virtual source 内の bare import がインストール済み package、workspace package、または別の virtual module に解決される場合もスキップされます。setup の登録方法は Plugins を参照してください。
ターゲット関数は "use client" ディレクティブを付けたモジュールから export し、その関数を境界の唯一の子として渡します。新しい consumer は Island を @takazudo/zfb から、zudo-react helper を @takazudo/ から import します。jsxImportSource に @takazudo/ を設定すると、zfb が所有する @takazudo/ と @takazudo/ entrypoint で JSX が変換されます。既存の bare alias は同じ package entrypoint を指す場合に使えます。issue #3932を参照してください。
import { Island } from "@takazudo/zfb";
import { Counter } from "./counter";
<Island><Counter /></Island>;子を動的に選ぶ形は production build で拒否されます。境界の内側で関数を選ぶのではなく、条件をそれぞれ具体的な境界の外側に置きます。
{showCounter
? <Island><Counter /></Island>
: <Island><Summary /></Island>}ファクトリから渡すターゲット
ファクトリはプレーンなオブジェクトのパラメーターからコンポーネントを受け取り、zfb がパラメーターとすべての直接呼び出しを証明できる場合に、そのコンポーネントを島のターゲットとして使えます。これは構文に基づく証明です。zfb がコンポーネントを探すためにファクトリを実行することはありません。既存の束縛形式は引き続き使えます。
| 束縛形式 | 使用できる構文 |
|---|---|
| メンバーアクセス | const Target = deps.Counter または const Target = deps["Counter"] |
| オブジェクトパターン | const { Counter: Target } = deps または const { Counter } = deps |
| パラメーターの分割代入 | function render({ Counter: Target }) または function render({ Counter }) |
メンバーアクセスのパラメーターは、rest ではない単純な識別子で、デフォルト値を持てません。メンバー式を囲む TypeScript 専用のラッパーは取り除いて判定します。パラメーターの分割代入ではプロパティ名の変更と省略記法を使えますが、rest、デフォルト値、ネストしたパターンは使えません。パラメーターを使えるのは、静的メンバーアクセスの対象か、const オブジェクトパターンの初期値としてだけです。書き込み、delete、別名への束縛、引き渡し、返却、スプレッドなど、それ以外の使用はネストしたクロージャー内でも証明に失敗します。分割代入したターゲットにも代入できません。let と var のターゲットは引き続き mutable target binding と診断されます。!Target、typeof Target、島以外のヘルパーに渡すといった Target の読み取りは登録に影響しません。登録に関係するのは <Island> の子として使う場合だけです。
パラメーターを持つ関数は、名前付き関数か、const に束縛した関数/アロー関数である必要があります。スキャン対象のモジュールグラフ内では、import と re-export の連鎖も含め、その関数への参照はすべて直接呼び出しの呼び出し対象でなければなりません。renderPanel(...)、(0, renderPanel)(...)、namespace.renderPanel(...) は直接呼び出しです。値として渡す、保存する、返す、bind/apply する、JSX として使う操作は関数の escape です。証明はスキャナーが発見したモジュールグラフを対象とし、実行時に生成される呼び出し元などは対象外です。
呼び出し箇所は 1 つ以上必要です。各呼び出しで宣言された位置にパラメーターを渡し、その位置以前の位置引数にはスプレッドを使えません。外側の引数はオブジェクトリテラルのままにします。たとえば renderPanel(deps) は、deps がローカル定数でも静的にはなりません。各呼び出しで選択されるターゲットは、同じ関数定義である必要があります。
静的なローカル依存スプレッド
ファクトリ呼び出しに渡すオブジェクトは、各 spread operand が識別子(既存の TypeScript 専用ラッパーと括弧は取り除いて判定)であり、同じモジュールの const オブジェクトリテラルに束縛 ID で解決できる場合、1 段だけスプレッドを使えます。関数内ローカルの定数も使えます。ソース初期化子自身にはスプレッドを含められません。これは範囲を限定した構文証明であり、再帰的なオブジェクト評価ではありません。createBoundary({ ...defaults }) は受け付けられる形ですが、createBoundary(deps) は deps がローカル定数でも拒否されます。
引数と各 spread source には、静的で計算されていないキーを持つ省略記法または通常のキー/値データプロパティだけを含められます。数値キーと bigint キーは既存の扱いを維持します。無関係な設定プロパティのネストしたオブジェクト値はデータであり、再帰展開しません。getter、setter、method、計算キー、ネストした source spread、未対応の spread があれば、後続のプロパティで隠れても引数全体が拒否されます。引数またはいずれかの source にあるトップレベルの __proto__ キーも拒否されます。引用符付きと省略記法の両方が対象です。
トップレベルの __proto__ は __proto__: value が prototype を設定するため除外されます。省略記法も、実行時には own data property になり得ますが、継承 property の推論、getter の呼び出し、prototype 構築のモデル化をしない限定的な証明のため除外します。既存の initializer reason 8 を維持します。
spread source は、その束縛に代入、更新、削除、メンバー変更、alias、分割代入での読み取り、メンバー読み取り、引数への引き渡し、返却、export、JSX spread、配列/位置引数 spread、コンテナ格納、その他の値 escape がない場合にだけ安全です。その値参照はオブジェクトリテラルの spread operand だけにできます。ネストしたクロージャー内も検査されます。呼び出しより後に見える書き込みでも source は拒否されます。これは制御フローや呼び出し順の証明ではありません。安全な source は繰り返し spread できます。source にはファクトリーパラメーター用の静的メンバー読み取り例外を適用しません。
外側の引数を左から右へ調べます。spread 同士、または spread と明示プロパティとの衝突は受け付けられ、最後の出現が有効なターゲットです。空または無関係な spread はそれ以前のターゲットを消しません。同じ source の繰り返しも使えます。Counter と Other は別々の client function とします。各行は個別の成功例であり、異なるターゲットを選ぶ呼び出しを 1 つのファクトリにまとめる例ではありません。
const defaults = { Counter };
const override = { Counter: Other };
createBoundary({ Counter }); // 既存の literal control は引き続き有効
createBoundary({ ...defaults }); // Counter
createBoundary({ ...defaults, Counter: Other }); // Other
createBoundary({ Counter: Other, ...defaults }); // Counter
createBoundary({ ...defaults, ...override }); // Other
createBoundary({ ...override, ...defaults }); // Counter
createBoundary({ ...defaults, ...defaults }); // Counter1 つのオブジェクトリテラル内に選択対象のキーを明示的に複数回書くと、値が同じでも、後続の spread が上書きしても拒否されます。これは引数の明示プロパティと各 source literal に個別に適用されます。選択しないデータキーの重複は引き続き許可されますが、別の境界がそのキーを選ぶ場合は証明に失敗します。
選択対象の最終値を解決する前に、引数とすべての source の構文全体を検証します。条件式かどうかを検査するのも最終値だけです。先行する条件値が証明済みの具体値で上書きされれば受け付けられますが、勝者が host.Counter ?? Counter、三項演算子、Counter || Other なら拒否されます。上書きで未対応構文を隠すことはできず、上書きされた関数はこの証明で登録されません。
同じファクトリの各呼び出しは、マーカー名だけでなく同じ関数定義を選ぶ必要があります。alias と re-export は既存の resolver で解決されます。同じマーカー名を持つ別定義は従来の marker collision 診断に進みます。
ファクトリ診断の接頭辞 target Target has unsupported initializer: は変わりません。該当する拒否理由は次のとおりです。
| 拒否される形 | 診断理由 |
|---|---|
createBoundary(deps)、未知の spread source、let source、import/parameter/alias の source、ネストした source spread、直接の object-expression spread (...{ Counter })、関数呼び出し結果、member または条件式 operand | call site at <loc> passes a non-literal argument |
禁止された source 参照/書き込み/export、計算キー、method、accessor、__proto__ キー、引数または source における選択対象の重複 | call site at <loc> passes a non-literal argument |
Counter を含まない安全な展開 | property Counter missing at call site <loc> |
勝者が host.Counter ?? Counter、三項演算子、Counter || Other | call site at <loc> selects Counter conditionally (??, ternary, ||) |
namespace import ではない runtime/opaque object receiver からの host.Counter | property Counter at <loc> is not a function binding(既存の resolver 理由が後ろに付く場合があります) |
| 有効な呼び出し間で異なる定義を選択 | call sites disagree: <loc> passes Counter, <loc> passes Other |
factory parameter を inner({ ...deps }) または inner(deps) に渡す | そのパラメーターの証明が必要な場合、既存の parameter deps is written or escapes at <loc> が優先されます。spread は関数間転送を追加しません。 |
直接 return する固定 wrapper を { Panel: BarePanelIsland } に格納、または汎用転送 boundary を { Island: ForwardBoundary } に格納 | boundary wrapper escapes into an opaque container |
汎用 wrapper を省略記法 { ForwardBoundary } で格納 | boundary wrapper escapes into an opaque object |
関数を Island JSON prop として渡す(両関数が有効な client export で、PropsReceiver が固定ターゲットの場合) | SSR error: ZR_ISLAND_PROPS PropsReceiver: ZR_PROPS_FUNCTION at props.component |
最初に該当するファクトリ理由が報告され、ソース位置と既存の汎用 rewrite suffix が続きます。トラブルシューティングに症状と修正方法をまとめています。
カスタマイズ可能なパッケージ島の合成境界
ホストによる差し替えが必要なパッケージは、公開 composition seam を用意します。デフォルトターゲットは固定した server boundary wrapper に置き、ホストは別の固定ターゲット wrapper を用意し、パッケージの server slot は描画済みの子をそのまま渡します。どの境界記述を描画するかは呼び出し側が選びます。コンポーネント関数や描画済み server child を Island の JSON prop として渡してはいけません。
ネストしたファクトリ、設定ゲート、ホスト override
2 段の通常の server factory に機能設定と描画ゲートを残せます。ホスト/デフォルトの選択は composition site が担当します。
// components/default-panel.tsx
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export function DefaultPanel() {
const count = signal(0);
return <button on:click={() => { count.value += 1; }}>Default panel: {count}</button>;
}
// components/host-panel.tsx
"use client";
import { signal } from "@takazudo/zfb/zudo-react";
export function HostPanel() {
const count = signal(0);
return <button on:click={() => { count.value += 1; }}>Host panel: {count}</button>;
}
// package-islands.tsx (通常の server slot とネストした設定 factory)
export function PanelSlot({ children }) {
return <>{children}</>;
}
export function createBodyEnd(settings) {
return function BodyEnd({ children }) {
if (!settings.enabled) return null;
return <PanelSlot>{children}</PanelSlot>;
};
}
export function createChrome(settings) {
const BodyEnd = createBodyEnd(settings);
return function Chrome({ children }) {
return <BodyEnd>{children}</BodyEnd>;
};
}
// pages/page.tsx (server composition site)
import { Island } from "@takazudo/zfb";
import { DefaultPanel } from "../components/default-panel";
import { HostPanel } from "../components/host-panel";
import { createChrome } from "../package-islands";
function DefaultPanelIsland() {
return <><Island when="load"><DefaultPanel /></Island></>;
}
function HostPanelIsland() {
return <><Island when="load"><HostPanel /></Island></>;
}
const settings = { enabled: true };
const Chrome = createChrome(settings);
const hasHost = true; // ここで具体的な host 実装が提供されています。
const suppressDefault = false;
export default function Page() {
return (
<Chrome>
{hasHost
? <HostPanelIsland />
: suppressDefault ? null : <DefaultPanelIsland />}
</Chrome>
);
}有効で host がなければ DefaultPanelIsland を描画します。host が用意されていれば host が優先され、HostPanelIsland を描画します。機能設定が無効なら、host があってもネストした factory が null を返します。そのページには marker、mounted 属性、ボタン、activation、機能用の pre-hydration shim が現れません。有効でデフォルトを抑制し、host がなければ境界は描画されません。host がある場合は、明示的な host override がデフォルト抑制より優先されます。
zudo-doc に必要な変更は、downstream の公開 composition seam migration です。設定はネストした factory に残し、host/default 選択を呼び出し側が所有する server composition site に移します。これは consumer の source/API migration であり、その consumer で検証が必要です。このドキュメントは downstream browser parity を主張しません。
固定ターゲットを使うコンポーネントマップ
マップを使う移行では、bare return の固定 wrapper と汎用転送 boundary entry の両方を書き換えます。function BarePanelIsland() { return <Island><DefaultPanel /></Island>; } を { Panel: BarePanelIsland } に格納すると boundary wrapper escapes into an opaque container で拒否されます。ForwardBoundary を { Island: ForwardBoundary } に格納しても同じ理由です。省略記法 { ForwardBoundary } では boundary wrapper escapes into an opaque object と診断されます。
各ターゲットを固定し、Fragment で囲んだ形を使います。
// components/panel-components.tsx
import { Island } from "@takazudo/zfb";
import { DefaultPanel } from "./default-panel";
import { HostPanel } from "./host-panel";
function DefaultPanelIsland() {
return <><Island when="load"><DefaultPanel /></Island></>;
}
function HostPanelIsland() {
return <><Island when="load"><HostPanel /></Island></>;
}
export const panelComponents = {
DefaultPanel: DefaultPanelIsland,
HostPanel: HostPanelIsland,
};この server-only map をページから import し、MDX entry に渡します。
// pages/blog/[slug].tsx
import { panelComponents } from "../../components/panel-components";
<post.Content components={panelComponents} />map module はページから到達可能である必要があります。Fragment は DOM 要素を追加せず、具体的な Island をスキャナーから見える形にします。function IslandWrapper({ children }) { return children ?? null; } のような plain SSR passthrough は通常の server rendering です。map key が Island だからといって SDK Island boundary にはなりません。map と wrapper は server 側に留まり、island prop としてシリアライズされません。
ロックされた acceptance fixture で移行全体を確認します。有効な行には marker が 1 つあり、stateful button が動作します。抑制された行にはページ marker と activation がありません。
| Fixture | ページ marker と操作 |
|---|---|
factory-static-spread | FactoryCounter が 1 つ。counter 0 → 1 |
factory-composition-default | DefaultPanel が 1 つ。default panel 0 → 1 |
factory-composition-host-override | HostPanel が 1 つ、default なし。host panel 0 → 1 |
factory-composition-settings-disabled | marker、feature button、mounted 属性、activation、feature shim なし |
factory-composition-default-suppressed | host なしで marker と activation なし |
factory-composition-host-with-default-suppressed | HostPanel が 1 つ、default なし。host panel 0 → 1 |
factory-map-default | DefaultPanel が 1 つ。default panel 0 → 1 |
factory-map-host-override | HostPanel が 1 つ、default なし。host panel 0 → 1 |
factory-map-settings-disabled | marker、feature button、mounted 属性、activation、feature shim なし |
negative scanner/SSR fixture の名前も維持します: factory-spread、factory-nullish、factory-escape、dynamic-child、factory-spread-mutated、factory-spread-nested、factory-spread-proto、factory-function-json-prop、factory-map-opaque、factory-map-forwarding-opaque、factory-map-opaque-shorthand です。最初の 4 件は既存の拒否 control で、追加した spread/map case は mutation、nested spread、prototype key、function JSON prop、bare-return wrapper、汎用転送、shorthand 格納を扱います。JSON prop の例は scanner 成功後の SSR で失敗します。
設定で描画を無効にしても、静的に発見されたターゲットが build-wide registry に残る場合があります。ページ marker の検査対象は描画された route であり、静的ターゲットが registry から消えることではありません。新しい行を加えても既存の root と / の route check を維持してください。
衝突診断で別々のターゲット関数が示されたら、どちらかの定義元の関数名を変更するか、意図しない定義を解決している import またはパッケージ export を修正します。import と re-export の alias でもマーカーは変わりません。スキャナーは displayName ではなく、定義元の関数名を使います。サーバーレンダリング時、displayName は未設定か関数名と一致している必要があります。一致しない値でターゲット名を変更することはできず、zfb は ZR_ISLAND_IDENTITY で失敗します。default target に安定した名前が必要なら export default function Counter() { ... } の形にします。
v3.2.0 以降は、同じパッケージ内の二つの定義や、パッケージのファイルとそれをバイト単位で同一にステージングしたコピーも別々のターゲットとして扱われ、この診断で失敗します。移行方法はパッケージ内で重複する島の定義を参照してください。
同梱の basic-blog テンプレートにある components/ は、典型的な実例であり、このテンプレート唯一の島でもあります。localStorage と matchMedia を読み取り、signal で自身の状態を管理し、アクティブなテーマを document.documentElement.dataset.theme にミラーします。鍵となるパターンは、初回ペイントでは決定論的で SSR セーフなデフォルトをレンダリングし、ブラウザー専用の読み取りは DOM の確定後に行います。以下はパターンを示す簡略版です。同梱のテンプレートはアイコンに Show を使い、ユーティリティクラスと SVG のマークアップも含みます。
"use client";
import { computed, getScope, signal } from "@takazudo/zfb/zudo-react";
type Theme = "light" | "dark";
export default function ThemeToggle() {
const theme = signal<Theme>("light");
const scope = getScope();
const isDark = computed(() => theme.value === "dark");
const next = computed(() => isDark.value ? "light" : "dark");
const ariaLabel = computed(() => `Switch to ${next.value} mode`);
scope.onActivate(() => {
try {
const saved = window.localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
theme.value = saved;
return;
}
} catch {
// Storage may be unavailable; use the system preference.
}
if (typeof window.matchMedia === "function") {
theme.value = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
});
scope.effect(() => {
const activeTheme = theme.value;
document.documentElement.dataset.theme = activeTheme;
try {
window.localStorage.setItem("theme", activeTheme);
} catch {
// The document theme still updates when storage is unavailable.
}
});
return (
<button
type="button"
aria-pressed={isDark}
aria-label={ariaLabel}
on:click={() => { theme.value = next.value; }}
>
{next} mode
</button>
);
}N 個の島に対してバンドラーが生成するもの
3 つの島(Counter・ThemeToggle・SearchBox)を持つプロジェクトの場合、islands のビルドステップは 1 つの共有バンドル を出力します。
dist/assets/islands.js すべての島は単一のバンドラー(デフォルトは Rolldown、ZFB_BUNDLER=esbuild なら esbuild)のエントリポイントへ静的にインポートされるため、共有バンドルにはすべての島コンポーネントが含まれます。その後 ProductionAssetPipeline が、最終出力を書き出す前にコンテンツハッシュ付きのファイル名へとリネームします。
dist/assets/islands-<hash>.js いずれかの島が内部で動的な import() を使っている場合、バンドラーはそれと並べてコード分割チャンクも出力することがあります。
dist/assets/islands-chunk-<hash>.js バンドルは各島を静的なマーカー名で登録し、モジュールの末尾で mountIslands() を呼び出して、ページ上の各ラッパーの識別子を検証し、対応するクライアントエントリが子 DOM を引き受けるようスケジュールします。
ProductionAssetPipeline がハッシュ化の単一の真実の源です — バンドラはまず安定した islands.js を書き出し、パイプラインがハッシュ付きへのリネームを行い、出力された HTML 内の注入されたスクリプト URL を書き換えます。下流の処理がファイル名を推測する必要はありません。
島がどうロードされるか
レンダリング後、各島のサーバー描画済み HTML は、コンポーネント識別子と transport メタデータを持つ <div> でラップされます。data-zfb-island* 境界属性は残り、ランタイムのコメント領域は子の部分ツリーに限定されます。
<div data-zfb-island="ThemeToggle"
data-when="load"
data-zfb-transport="json/1"
data-zfb-protocol="zudo-react/1"
data-zfb-build="build-identity"
data-props="{}">
<!-- zudo-react's child-subtree markers live here -->
<button type="button" aria-pressed="false" aria-label="Switch to dark mode">Dark mode</button>
</div>data-when 属性はクライアントエントリの実行タイミングを制御します。常に存在し、デフォルトは "load" です。デフォルト以外のタイミングを指定する JSX API は <Island> ラッパー です。メディアクエリによるハイドレーションでは、when="media" と media クエリの両方を渡します。
import { Island } from "@takazudo/zfb";
import MobileMenu from "./MobileMenu";
export default function Header() {
return (
<Island when="media" media="(max-width: 768px)">
<MobileMenu />
</Island>
);
}"visible"・"idle"・"media" 戦略はそれぞれ data-when 属性を出力します。"media" はさらに CSS クエリ文字列を持つ data-media 属性も出力します。
<!-- visible timing -->
<div data-zfb-island="SearchBox" data-when="visible" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>
<!-- idle timing -->
<div data-zfb-island="Counter" data-when="idle" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>
<!-- media timing — hydrates when (max-width: 768px) first matches -->
<div data-zfb-island="MobileMenu" data-when="media" data-media="(max-width: 768px)" data-zfb-transport="json/1" data-zfb-protocol="zudo-react/1" data-zfb-build="build-identity" data-props="{}">…</div>少なくとも 1 つの島を含むビルドには、1 つの <script> タグ がプロジェクト全体で <head> に注入されます。
<script type="module" src="/assets/islands-<hash>.js"></script>共有 islands バンドル(islands-<hash>.js)はすべての島コンポーネントを含みます。スキャナが発見した静的なマーカー名(data-zfb-island 属性の値)で各島を登録し、その後 mountIslands() を呼び出します。これはページ上のすべての [data-zfb-island] 要素を走査し、ラッパーのメタデータを検証して JSON の data-props を読み取り、予定されたエントリの実行時に既存の子 DOM を引き受けます。
マウント状態を監視する
ランタイムは、マウント後のマーカー data-zfb-island-mounted を 2 種類の島要素に公開します。ハイドレートされる data-zfb-island マーカーと、SSR をスキップする data-zfb-island-skip-ssr マーカーの両方が対象です。この属性は、クライアントルートのマウントに成功した後だけ追加されます。そのため、遅延されている島には data-when の条件を待っている間はマウント済みマーカーがなく、利用者は次のような CSS セレクターでハイドレーション前のスタイルを切り替えられます。
[data-zfb-island]:not([data-zfb-island-mounted]) {
opacity: 0.7;
}このマーカーは 「クライアントルートのマウントに成功した」 ことを示します。観測用の属性であり、重複マウントを防ぐのは生きたルートハンドルです。マーカーが付いていても、その後の非同期アプリケーション処理が完了したとは限りません。
島がアンマウントされると、マーカーは削除されます。実際のクライアントルーターのボディスワップでは、変更のない永続化された島は、受信側の <body> に同じ data-zfb-transition-persist ID があれば、マーカーとマウント済みインスタンスを維持します。破棄される島はマーカーを失い、受信したマークアップから再びマウントされます。永続化された島のコンポーネント識別子または props が変わった場合、ランタイムは古いルートを破棄してマーカーを削除し、hydrate ではなく render モードで新しいルートを作ります。マウント成功後にマーカーを再度追加します。また、新しいランタイムモジュールは、自身がまだマウントしていない要素に残った古いマーカーを取り除き、自身のマウント後に新しいマーカーを書き込みます。
共有バンドルモデルの帰結
すべての島が単一のファイルにまとめてバンドルされるため、次のことが言えます。
いずれかの島を持つすべてのページが、全島のコードを読み込みます。 単一の
islands-<hash>.jsバンドルには、プロジェクト内のすべての島コンポーネントが含まれます。ThemeToggleしか使わないページでも、CounterとSearchBoxのコードをダウンロードします。これは、ページ単位の読み込み粒度を、よりシンプルなビルドパイプラインとページをまたいだ優れたキャッシュ効率と引き換えにするものです。島のないページには islands スクリプトが入りません。 ページに島マーカーが含まれない場合、ビルドパイプラインはそのページへの共有 islands
<script>注入をスキップします。使われている場合、他の client-script 仕組みは引き続き JavaScript を追加できます。いずれかの島の変更が、単一のバンドルを再ハッシュします。 いずれかの島コンポーネントを追加または変更すると新しいコンテンツハッシュが生成され、それがすべてのページについてブラウザのキャッシュされたバンドルを無効化します。トレードオフは、1 つのキャッシュエントリがすべての島をカバーすることです。バンドルが一度キャッシュされれば、プロジェクト内のすべてのページが同じキャッシュヒットの恩恵を受けます。
共有バンドルの実測ベースライン
制御された v3 フィクスチャ調査では JavaScript の削減は行われていません。現在のバンドルを回帰検出用のベースラインとして記録しています。Darwin arm64 と Linux x64 で、ワークスペース解決と packed パッケージ解決の両方を使い、島なし、イベントのみ、scalar signal、Show/For、modelValue、ブログメニューとテーマ切り替え、JSON API、2 つの島を含むページの 8 ケースを実際のコンシューマーで測定しました。各プラットフォームの契約では raw と gzip の許容増加量を 0 にしています。
| フィクスチャ | Darwin workspace raw / gzip | Darwin packed raw / gzip | Linux workspace raw / gzip | Linux packed raw / gzip |
|---|---|---|---|---|
| 島なし | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 |
| イベントのみ | 49,830 / 17,004 | 49,892 / 17,027 | 49,830 / 17,003 | 49,892 / 17,030 |
| scalar signal | 49,838 / 17,009 | 49,900 / 17,037 | 49,838 / 17,005 | 49,900 / 17,036 |
| Show/For | 49,985 / 17,082 | 50,047 / 17,111 | 49,985 / 17,081 | 50,047 / 17,111 |
| modelValue | 49,829 / 17,012 | 49,891 / 17,045 | 49,829 / 17,014 | 49,891 / 17,046 |
| ブログメニューとテーマ切り替え | 50,180 / 17,138 | 50,242 / 17,166 | 50,180 / 17,137 | 50,242 / 17,167 |
| JSON API | 49,984 / 17,096 | 50,046 / 17,127 | 49,984 / 17,098 | 50,046 / 17,123 |
| 2 つの島 | 50,106 / 17,103 | 50,168 / 17,132 | 50,106 / 17,098 | 50,168 / 17,134 |
各プラットフォームとモードで各ケースを 2 回ビルドし、出力された dist 全体のファイル一覧を比較しました。記録された島ありケースはそれぞれ entry が 1 つで、後続チャンクはありませんでした。分割が発生した場合、サイズ契約では entry、shared、lazy のすべての JavaScript ファイルを数えます。raw は全ファイルのバイト数の合計です。gzip は各ファイルに node:zlib gzipSync を level 9、mtime 0 で個別に適用してから合計します。HTTP 転送時の圧縮サイズは異なることがあります。
調査時は @takazudo/zfb 3.0.0、Node v24.14.0、zlib 1.3.1-e00f703、esbuild 0.25.12、lockfile の Hono 4.12.25 を固定しています。Darwin のベースラインソースは 897f288c013e24ff4fde01483a55d8a1945fc964 です。Linux x64 のプローブは GitHub Actions health run 36831966429 の PR merge ref afa523768b3de897859521e1bf940179191f50ab(PR head 99726c74a48df1a0658cd6d9f5c494d06d4f2d40)で行われ、Darwin の歴史的コミットでの再実行ではありません。Linux の raw 合計は Darwin と一致しましたが gzip 合計は異なり、実際に生成した islands JavaScript の全ファイルで SHA-256 が異なります。これはプラットフォームごとのサイズベースラインであり、プラットフォーム間のバイト一致や意味的同等性を示すものではありません。Linux の結果は固定ツールチェーンを使った x64 GitHub Actions の 1 回の実行に基づきます。他の Linux アーキテクチャや将来のツールチェーンについては確認していません。
現在の契約は調査時から更新されています。いまは @takazudo/zfb 4.5.4 と同じ Node・zlib・esbuild・Hono のバージョンを固定しており、正確な上限値は research/(Darwin arm64)と decision-linux-x64.json(Linux x64)にあります。たとえば島 1 つのイベントのみのページは、両プラットフォームともワークスペースモードで raw 64,535 / gzip 22,195 バイトが上限です。許容増加量は引き続き 0 です。
契約はレビュー済みの遷移を通してのみ変わります。契約が承認していない形で Cargo.lock が変わると、health.yml の island-size チェックは失敗し続けます。新しいネイティブ証拠を集め、影響する契約を再固定(--stage のあと --apply)するまで解消しません。Linux x64 の証拠は GitHub Actions のアーティファクトから取ります。Darwin arm64 の証拠はネイティブの island-size-darwin.yml ワークフローから取るか、オーナーが手元の Mac で実行して証明したものを使います。後者は scripts/ が、内部の Cargo 再配線と rustls セキュリティのロック遷移という 2 つのレビュー済みの理由に限って受け付けます。どちらの経路も、それだけで上限値や許容量を変えることはありません。
2 つの実測マトリクスを再現するコマンドは、リポジトリの測定と再実行の READMEを参照してください。ガードは Darwin arm64 では decision.json、Linux x64 では decision-linux-x64.json を選び、未対応ホストや異なるプラットフォームの測定結果を拒否します。両方の実行後、保存したファイルを選択された契約と照合します。
node research/v3-island-size/check-budget.mjs --workspace "$ZFB_SIZE_OUT/real-workspace/measurement.json" --packed "$ZFB_SIZE_OUT/real-packed/measurement.json"単一のランタイム
すべての島は @takazudo/zfb に同梱される zudo-react を使います。コンポーネントはセットアップ中に signal を作り、クライアントはラッパーの識別子と厳格な JSON props を検証してから、既存の子 DOM を引き受けます。プロジェクトごとのフレームワーク設定や互換エイリアスはありません。JSX 方言とコンポーネントモデルは zudo-react の概要を参照してください。
サーバーとクライアントの境界については zudo-react ランタイムを参照してください。
サードパーティウィジェットの埋め込み
自分でマウントするウィジェットには、zudo-react の島の中に空の専用要素を渡せます。ウィジェットが使う Preact や React を含むフレームワークは、node_modules からそのままウィジェットのバンドルに入ります。そのライブラリが要求する peer dependency としてフレームワークをインストールしてください。v3 の「Preact/React の JSX ランタイムをインポートしない」という規則は、zudo-react が描画するコンポーネントに適用され、専用ホストにマウントする不透明なウィジェットのバンドルには適用されません。ウィジェットの VNode を zudo-react に渡したり、zudo-react の子要素がある領域をウィジェットに変更させたりしないでください。
以下は仮のアダプターモジュールを使う例です。mount(host) が同期的な destroy() メソッドを持つインスタンスを返す形にしています。実際にはライブラリのマウント API と破棄 API に置き換えてください。マウントがインスタンスを返す前に例外を投げた場合、アダプターは途中で確保したリスナーやタイマーなどを自ら元に戻す必要があります。ホストを空にするだけでは DOM ノード以外の資源は解放できません。
"use client";
import { getScope, h, signal, type Ref } from "@takazudo/zfb/zudo-react";
type WidgetInstance = { destroy(): void };
type WidgetModule = { mount(host: Element): WidgetInstance };
type WidgetLoader = () => Promise<WidgetModule>;
const loadWidget: WidgetLoader = () => import("./widget-adapter");
export function ThirdPartyWidget() {
const host: Ref<Element> = { current: null };
const status = signal("Loading widget…");
const scope = getScope();
const abortSignal = scope.abortSignal;
scope.onActivate(() => {
let instance: WidgetInstance | undefined;
void loadWidget().then((widget) => {
if (abortSignal.aborted) return;
const element = host.current;
if (!element) return;
try {
instance = widget.mount(element);
status.value = "";
} catch {
element.replaceChildren();
status.value = "Widget unavailable.";
}
}).catch(() => {
if (!abortSignal.aborted) status.value = "Widget unavailable.";
});
return () => {
const mounted = instance;
instance = undefined;
try {
mounted?.destroy();
} finally {
host.current?.replaceChildren();
}
};
});
return h("section", null,
h("div", { ref: host }),
h("p", { role: "status", children: status }),
);
}// widget-adapter.ts — illustrative library-specific adapter
import * as thirdParty from "some-widget";
export function mount(host: Element) {
const onResize = () => { /* update adapter-owned measurements */ };
window.addEventListener("resize", onResize);
const timer = window.setInterval(() => { /* adapter-owned polling */ }, 1000);
let released = false;
const release = () => {
if (released) return;
released = true;
window.removeEventListener("resize", onResize);
window.clearInterval(timer);
};
try {
const widget = thirdParty.mount(host); // The library must roll back its own partial work on failure.
return { destroy() { try { widget.destroy(); } finally { release(); } } };
} catch (error) {
release(); // Roll back allocations made before mount returned.
throw error;
}
}onActivate 自体は同期的に保ちます。onActivate(async () => ...) が返す Promise はアクティベーション時に拒否されます。import() はコールバックの中で開始し、SSR 中にも実行されうるモジュールのトップレベルでは開始しません。インポート待ちの間にナビゲーションでスコープが破棄される場合に備え、マウント前に中断シグナルを確認します。クリーンアップはアクティベーションから同期的に返すか、セットアップ中に登録します。await の後にセットアップ専用の onCleanup を呼ぶと失敗します。アダプターの mount は完全なインスタンスを返すか、例外を投げる前に途中の確保を自分で元に戻す必要があります。返されたインスタンスは島の破棄時に一度だけ破棄されます。
ブラウザバンドルのモードと設定
island のコードは、クライアントスクリプト、module worker、ページ / SSR バンドルと同じコンパイル時モード値を使います。
| コマンド | import.meta.env.DEV | import.meta.env.PROD | process.env.NODE_ENV |
|---|---|---|---|
zfb dev | true | false | "development" |
zfb build | false | true | "production" |
モードは minify の有無から独立しています。追加の bundle.loaders と bundle.define の設定も、islands とそこから起動する worker で共有されます。define の値は公開コードの生の置換式なので、secret を含めてはいけません。
TypeScript はこれらのバンドルコントラクトを推論しません。island のコードでモード値、カスタム loader の import、raw import、define 名を zfb check と tsc に通すには、対応する ambient 宣言を追加してください。
?raw でテキストをインポートする
island、またはその import グラフにある first-party モジュールは、厳密に 1 つの形式で任意のプロジェクトローカルファイルをテキストとして読み込めます。
"use client";
import shaderSource from "./shaders/noise.frag?raw";ターゲットの拡張子は問いません。bundle.loaders が結果を変えることもありません。ターゲットは妥当な UTF-8 として読み込まれ、文字列として default export され、終端の依存として扱われます。つまり .js?raw ファイルはコードではなくテキストです。サポートされるのは、リテラルの相対 specifier と厳密な ?raw サフィックスを持つ静的な default import だけです。named import、namespace import、side-effect import、type-only import、dynamic import、re-export、その他のクエリ、追加パラメータ、リテラルでないパス、プロジェクトルート外のターゲットは、サポートされる形式を示すメッセージとともに失敗します。
zfb dev 中、island とクライアントスクリプトのグラフにある raw ターゲットは、それぞれのライブ監視セットへ追加されます。そのため import 元が変わっていなくても、テキストファイルの編集・削除・再作成によってブラウザバンドルが invalidation されます。同じテキスト変換はページ / SSR パスでも動作し、raw edge はルートの依存追跡にも加わりますが、それによって新しいファイルシステム監視ルートが追加されるわけではありません。デフォルトの監視ルート外(たとえば lib/ 配下)にある SSR 専用の raw ターゲットは、絶対パスの extraWatchPathsでカバーする必要があります。そうしなければ、編集しても dev のファイルシステムイベントは発生しません。
Module worker
バンドラーを実行する前に zfb が探索できる、リテラルな module-worker constructor を使います。
"use client";
const searchWorker = new Worker(
new URL("./workers/search.worker.ts", import.meta.url),
{ type: "module" },
);最初の URL 引数は、クエリや fragment のない、厳密なプロジェクトローカル相対パスの JS / TS ファイルを指す文字列リテラルでなければならず、worker options は type: "module" を選ぶ必要があります。zfb は worker ソースをサーバーグラフへ入れず、各 worker を自己完結したブラウザエントリとしてバンドルし、URL を次の形へ書き換えます。
./worker-<encoded-project-relative-path>.js?v=<graph-hash>island が所有する worker は / 配下へフラットに出力されます(base を設定していればそのプレフィックス付き)。安定ファイル名は、完全なプロジェクト相対ソースパスを可逆にエンコードしたものです。区切りは -s-、ドットは -d-、リテラルのハイフンは -h-、その他のバイトは -xHH- になります。たとえば src/ は worker-src-s-search-s-index-d-worker-d-ts.js になります。?v= クエリは厳密に 8 文字の小文字 16 進数で、推移的な import、ネストした worker、raw ファイル、関連する TypeScript 設定を含む first-party グラフ、または出力に影響するバンドル / resolver 入力が変わると更新されます。dev はその closure を監視し、worker edge がなくなると古い companion を削除します。
予測可能なプレフィックスは、worker ごとの response policy のアンカーにできます。たとえば Cloudflare 形式の public/_headers ファイルは次の設定から始められます。
/assets/worker-*.js
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self'ディレクティブは worker が実際にアクセスする対象に合わせて調整してください。パス形式の base は _headers のルートにも付けます。たとえば base: "/docs/" なら / が必要です。一方、https: のような絶対 URL の base では、そのアセットオリジン側に同等の / ヘッダールールが必要です。サイト側の _headers ファイルから別オリジンを設定することはできません。クライアントスクリプトが所有する worker は、別の / ディレクトリに置かれるため、同じ base / オリジンの方針に従った対応ルールが必要です。
前処理パスはインストール済みの node_modules をたどりません。third-party の推移的な worker constructor はパッケージ側のツールに委ねられます。同じリテラルな new URL(..., import.meta.url) 形式を使う SharedWorker は、名前付きのハードエラーになります。それ以外のリテラルでない、または module でない constructor 形式はこのコントラクトの対象外で、書き換えられることを期待すべきではありません。
import.meta.glob のサポート
zfb は Vite の import.meta.glob(...) マクロをサポートしています。パターンにマッチするすべてのファイルを 1 回の呼び出しでインポートするもので、サブコンポーネントやデータファイルのディレクトリを自動登録するのに便利です。サポート範囲は意図的に狭く保たれています。zfb はこの Vite 専用マクロを、ビルド時に、島のバンドラーがファイルを目にする前に Rust 側で展開します。そのため結果はどちらのバンドラーが動くかに左右されません。展開が理解できる唯一の形式だけが受け付けられ、それ以外はサイレントなミスコンパイルではなくビルドエラーになります。
サポートされる形式
"use client";
// Eager + string-literal pattern, anchored at this file's own directory.
const pages = import.meta.glob("./items/*.tsx", { eager: true });これはビルド時に、マッチした各相対パスをそのモジュールの名前空間オブジェクトへマッピングする素のオブジェクトリテラルへと展開されます。Vite が生成するものと同じ形です。
{
"./items/a.tsx": /* module namespace */,
"./items/b.tsx": /* module namespace */,
}サポートされる形式の要件は次のとおりです。
第 2 引数は 厳密に
{ eager: true }でなければなりません。Vite のデフォルト(第 2 引数なし)は lazy な形式であり、ここではサポートされません。パターンは 文字列リテラル でなければなりません。変数、テンプレートリテラル、パターンの配列はサポートされません。
パターンは インポート元ファイル自身のディレクトリ配下 に解決されなければなりません。
.でディレクトリを抜け出すパターンは即座に拒否されます。. / import・query・asオプションは使えません。 認識されるのは素の{ eager: true }オブジェクトだけで、それ以外のキーがあるとビルドが失敗します。
どこで動作するか
| 場所 | サポート |
|---|---|
サーバー専用のページ・レイアウト・コンポーネント("use client" 島から到達不能なもの) | あり — 汎用の SSR シャドウコピーバンドラ経由で、以前から常にサポート |
"use client" 島自身のファイル | あり(issue #1404 以降) |
"use client" 島から推移的にインポートされるモジュール | あり(#1404 以降、上記と同じ制約)。島の import グラフに含まれ、展開済みの実体あるシャドウコピーファイルとして書き出される場合 |
| 別の島到達可能な glob にマッチしたことでだけ到達するファイル(raw-mirrored な glob ターゲット、またはサブツリー内の companion ファイル) | なし — 後述の「サポートされない形式」を参照 |
*.client.{ts,tsx,js,jsx} クライアントスクリプト(およびそれがインポートするもの) | なし — 後述の「クライアントスクリプト」を参照 |
issue #1404 以前は、島から到達可能な あらゆる import.meta.glob(サポートされる eager + 文字列リテラル形式であっても)が、ハードなビルドエラーでした(issue #1387 での応急処置)。現在の islands ビルドは、サポートされる各 glob 呼び出しをあらかじめ展開した状態で、島から到達可能なソースツリーの一時的なシャドウコピーを実体化します。これは SSR バンドラが issue #665/#670 以降、サーバー専用モジュールに対して使ってきたのと同じ手口です。
重要な境界が 1 つあります。シャドウは、島から到達可能な各 glob モジュールのディレクトリ配下も raw-mirror し、glob にマッチしたターゲットファイルをバンドラーが読めるようにします。しかしそれらの raw-mirrored なターゲット/サブツリー内ファイルは再帰的には展開されません。その中にさらに import.meta.glob(...) がある場合、zfb は未展開の Vite 専用マクロをブラウザへ配信する代わりに拒否します。同じファイルが通常の import によって島のグラフにも含まれているなら、そのファイルは展開済みの実体あるシャドウコピーになり、サポートされる glob は引き続き動作します。
サポートされない形式はビルドを失敗させる(dev では警告してスキップ)
以下のいずれかは、問題のファイル名を示すメッセージとともに zfb build を失敗させます。zfb dev 中は同じメッセージが警告としてログ出力され、そのリバンドルのティックはスキップされるため、ファイルを直して再保存する間も dev サーバーは動き続けます。
デフォルトの lazy 形式 — オプションなしの
import.meta. glob(". / items/ *. tsx") { eager: false }リテラルでないパターン(変数、テンプレートリテラル、計算された式)
import・query・asオプションファイル自身のディレクトリを抜け出すパターン(
.). / プロジェクトルート外や
node_modules配下に存在しながら、なお島から到達可能な glob モジュール(シャドウコピーへミラーする対象が存在しない)raw-mirrored な glob ターゲット、またはサブツリー内の companion ファイルとしてだけ到達する JS 風のファイル内にある
import.meta.glob(...)呼び出し
raw-mirrored ファイルのチェックは意図的に保守的です。glob されたサブツリー内の未使用の JS 風 sibling(.js、.jsx、.ts、.tsx、.mjs、.cjs、.mts、.cts)であっても、実際の glob 呼び出しを含んでいれば検出されます。シャドウ側では、その raw ファイルをバンドラーがあとで読むかどうかを証明できないためです。glob を島から到達可能なモジュールへ hoist するか、glob されたサブツリーの外へ移動するか、明示的な静的インポートへ置き換えてください。
クライアントスクリプト: サポートされず、ビルド時にも検出されない
import.meta.glob は クライアントスクリプト(*.client.{ts,tsx,js,jsx}、およびそれらがインポートする任意のモジュール)では サポートされません。?raw、module worker、プラグインの前処理が必要なグラフは一時的なミラーを使う場合がありますが、その段階は意図的に glob を展開しません。そのため呼び出しは意味上未展開のままブラウザへ配信されます。上記の島のケースとは異なり、これはビルド時には検出されません。zfb build は成功し、スクリプトが実際に実行されるときにブラウザが例外を投げます。import.meta.glob は Vite の外では undefined だからです。クライアントスクリプト内で glob のようなファイル一覧が必要な場合は、以下の代替手段のいずれかを使ってください。
代替手段
プラグインの仮想モジュール — プラグインの
setupフック内で(node:fsや glob ライブラリなどを使って)Node 側で自分でファイル一覧を計算し、addVirtualModule経由で公開します。フックの完全な契約と実際に動作する仮想モジュールの例は Plugins を参照してください。明示的な静的インポート —
import文を手で書き出すか、あるいは小さなプリビルドスクリプトで実際の.ts/.tsxファイルを生成し、それを zfb が通常どおりバンドルします。
島ではないクライアント JS のための抜け道
島はステートフルな UI コンポーネントをカバーします。それ以外のクライアントサイド JavaScript のニーズには、標準的な HTML の仕組みを直接使ってください。
インラインスクリプト — ページの TSX やレイアウトに直接 <script> タグを書きます。
export default function Layout({ children }) {
return (
<html>
<head>
<script rawHtml={`document.documentElement.dataset.theme = localStorage.getItem('theme') ?? 'light';`} />
</head>
<body>{children}</body>
</html>
);
}これは、スタイルシートのパース前に実行されなければならない同期的なハイドレーション前の処理(FOUC 防止、テーマ初期化、アナリティクスのセットアップ)に適したツールです。
外部スクリプト — public/ または CDN の任意の .js ファイルを参照します。
defer、async、nonce などの標準的な script 属性は JSX で直接使えます。外部 script は通常の要素として記述してください。rawHtml は信頼できるインライン script のテキストに使います。
<script src="/scripts/analytics.js" defer />
<script src="https://cdn.example.com/lib.js" defer />クライアントスクリプトまたはカスタムビルドステップ — zfb は 2 つ目のブラウザエントリパイプラインとして *.client.* エントリを自動的にバンドルします。それ以外の TypeScript モジュールには、別の esbuild、Rolldown または Rollup ステップを追加し、その出力を <script src> から参照してください。"use client" グラフまたは .client.* エントリから到達できないモジュールは、サーバー専用のままです。
できないこと: 通常の("use client" でない).ts や .tsx モジュールをページからインポートして、そのブラウザ側のコードがクライアントに届くことを期待すること。ディレクティブを持たないモジュールはサーバー専用です。SWC がビルド時にコンパイルして評価し、そのバイトは出力に一切含まれません。
島を使わないほうがよいとき
島は JavaScript を配信します。それにはコストがあります。追加する前に、それなしで問題を解決できないか自問してください。
DOM のクラス入れ替えトグル(アコーディオン、ディスクロージャーメニュー、表示・非表示パネル)は、数行のプレーンな CSS や小さなインラインの <script> で解決できることがよくあります。ネイティブの <details> / <summary> 要素は JavaScript なしでアコーディオンの挙動を扱います。
<details>
<summary>Frequently asked question</summary>
<p>The answer goes here.</p>
</details>CSS のみのアプローチ(:target・:checked + <label>・@starting-style)は、かつて JavaScript を必要とした多くのインタラクティブなパターンを扱えます。
島が適したツールとなるのは次の場合です。
コンポーネントが、単一のインタラクションを超えて存続しなければならない状態を持つ場合(例: カート、ユーザーセッション、複数ステップのフォーム)。
コンポーネントがビルド時には利用できないブラウザ API に依存する場合(
canvas・WebGL・getUserMedia・リアルタイムデータ)。そうでなければコンポーネントを 2 回(サーバーレンダリング用に 1 回、クライアント用に 1 回)書いて手動で同期し続けることになる場合。
正直な答えが「クリックでクラスを 1 つ切り替えたいだけ」なら、まず CSS か小さなインラインスクリプトに手を伸ばしてください。「2 回書くことになるステートフルな UI」に島を、というのが正しい判断基準です。
JSX ラッパーとハイドレーションオプションについてさらに詳しくは <Island> を参照してください。パイプラインの形についてさらに詳しくは Build pipeline と Build engine を参照してください。