本書は、あなたの Design 3(TSX-FX + FiberFX) をベースに、指定の変更点(read-set / パス単位 deps、Tx 同期スコープ限定、最小権限 capability+型付き Port、Msg/reducer をオプション化)を取り込み、さらに Design 1/2/4 の「筋の良い部分」を積極的にマージした “破れない” 仕様としてまとめたものです。
| 用語 | 意味 |
|---|---|
| Pure Zone | 関数コンポーネント本体(= render)。純粋・閉包禁止・外部参照禁止 |
| Fx Zone | 副作用が許されるコールバック内部(イベント / ライフサイクル同期) |
| Snapshot | render 入力として渡される不変値(props/state/context の “その時点の写像”) |
In<T> |
Snapshot 型(deep immutable / deep readonly) |
Tx |
state 更新を溜めて 1 回の commit にするためのトランザクション(同期スコープ限定) |
| Capability | dom/time/storage 等の外部アクセス権。注入されない限り使えない |
| Port(型付きポート) | UserApi のように「許可された外部操作だけ」を型で列挙した capability(OpenAPI 連携も想定) |
| read-set | use.sync などのコールバックが “読む” In<T>(できればパス単位)集合 |
| deps | read-set 由来の依存集合(React の依存配列に相当)。開発者は書かない |
-
Pure Zone(render)
- 外部システムアクセス禁止(DOM/Network/Time/Random/Storage…)
- 外部値参照禁止(後述の「閉包禁止」)
- 入力は
In<T>のみ(props/state/context はすべて Snapshot) - render 中の更新禁止(
Txを得られない)
-
Fx Zone(副作用コールバック)
- 命令的・手続き的 OK
- 外部アクセスは capability 経由のみ
- state 更新は
Tx経由のみ(同期スコープ限定)
In<T>は deep readonly(ネストした配列・オブジェクトまで変更不可)In<T>は render 実行中不変(スナップショット)In<T>から得た参照(サブオブジェクト)もIn<...>として扱う
擬似定義:
type In<T> = DeepReadonly<T> & SnapshotBrand;component 本体(Pure Zone)で参照可能な値は 以下のみ:
- 引数(props)
use.*/state/ctx宣言で得た値(すべてIn<T>)- ローカル
let/const pure importされた 純粋関数・静的定数static const(コンパイル時定数)
禁止
- モジュールスコープの
let/var、可変シングルトン、外部キャッシュ等 window/document/fetch/Date.now/Math.randomなど ambient 値fx関数の呼び出し(Fx Zone にしか存在しない)
コンパイルエラー例:
let globalCounter = 0;
component Bad(props: In<{}>) {
globalCounter += 1; // ❌ 外部値参照/変更(自由変数)
return <div />;
}import pure { format } from "./format"; // ✅ pure 関数のみ
import fx { createUserApi } from "./ports/userApi"; // ✅ fx でのみ使用可pure importは Pure Zone で使用可能fx importは Fx Zone でのみ使用可能(Pure Zone で参照不可)
component Counter(props: In<{ initial?: number }>)
state {
count: number = init(props.initial ?? 0);
}
ctx {
locale: string <- LocaleCtx;
}
uses { Dom, Time } // そのコンポーネントが “使える可能性がある” capability を宣言
{
// Pure Zone:入力は in に集約される
// in.props, in.state, in.ctx はすべて In<...>
return <div>{in.state.count} ({in.ctx.locale})</div>;
}in.propsはpropsと同値(In<Props>)in.state.<name>は Snapshot(In<T>)<name>$は更新対象ハンドル(State<T>/Model<S,Msg>など)
※$付きは Snapshot ではない。render 入力ではない。
init(expr)は 初回 mount 時のみ評価される(lazy init)exprは Pure でなければならない(外部アクセス不可)
namespace use {
pure fn state<T>(init: T | pure fn() -> T): [In<T>, State<T>];
}
type State<T> = opaque { /* 更新対象ハンドル */ };
type Tx = {
set<T>(s: State<T>, next: T): void;
update<T>(s: State<T>, f: pure fn(prev: In<T>) -> T): void;
// 複数 state を手続き的にまとめたい場合
batch(body: fx fn(tx: Tx) -> void): void;
};use.stateは Pure(呼ぶだけでは副作用を起こさない)- 更新は Fx Zone で渡される
Tx経由のみ
JSX の onClick などに渡すハンドラは必ず event で生成する。
<button
onClick={event |ev, { userApi }, tx| {
tx.update(count$, c => c + 1);
userApi.logAction({ kind: "inc" }); // 例(Port)
}}
/>capsの destructuring で 要求した能力しか存在しない- したがって
{ userApi }を要求しているハンドラ内でdomは使えない(型エラー)
namespace use {
// passive(paint 後)
pure fn sync(effect: fx fn(caps: FxCaps, tx: Tx) -> void | Cleanup): void;
// layout(commit 直後〜paint 前)
pure fn layoutSync(effect: fx fn(caps: FxCaps, tx: Tx) -> void | Cleanup): void;
pure fn syncOnce(effect: fx fn(caps: FxCaps, tx: Tx) -> void | Cleanup): void; // deps=[]
pure fn syncAlways(effect: fx fn(caps: FxCaps, tx: Tx) -> void | Cleanup): void; // 毎 commit(警告推奨)
}
type Cleanup = fx fn() -> void;use.sync/layoutSync自体は Pure(登録するだけ)- 実行されるのは commit フェーズ以降(後述)
use.sync / use.layoutSync の依存集合 deps は、コールバック本体が参照する Snapshot の read-set から導出する。
- 対象となる read は、以下の Root から始まるもの:
in.props/in.state/in.ctxuse.state等で得た Snapshot 変数(例:count: In<number>)use.memoが返すIn<T>(後述)
read-set は可能な限り **“パス”**で表現する。
- 例:
in.state.user.idを読んだ → dep はstate.user.id - 例:
in.props.items.lengthを読んだ → dep はprops.items.length - 例:
in.state.map[key](動的キー) → dep は保守的にstate.map
Root∈{ props | state | ctx }PathはRoot.(identifier|[stringLiteral]|.length|...)*の列- 動的キー・未知の getter・外部 pure 関数に渡す場合は、最長の確定接頭辞で丸める
FiberFX コンパイラは use.sync の引数(fx クロージャ)の AST を解析し、read-set を収集する。
- 基本:静的解析で抽出
- 追加:
pure fnについて、コンパイラが read-set シグネチャを生成し、呼び出し側で合成できる(設計 3 を実用化するための要点)- 例:
pure fn getId(u: In<User>) { return u.id } use.sync内でgetId(in.state.user)を呼ぶと、state.user.idを deps に入れられる
- 例:
メタデータが無い pure 関数に
In<T>を渡す場合は、安全側に倒して「引数のルート(または確定パス接頭辞)を読んだ」とみなす。
- 各 dep パスの値を commit 時点の snapshot から取り出し、
Object.isで比較する - いずれかが変化したら
cleanup → setupを行う - 変化していなければ何もしない
syncOnce: deps を空集合扱い(mount/unmount のみ)syncAlways: deps を特別扱いし、毎 commit 実行(基本は非推奨。計測等に限定)
Tx は以下の Fx コールバック引数としてのみ存在する:
event |..., tx| { ... }use.sync(|..., tx| { ... })use.layoutSync(|..., tx| { ... })
禁止
Txを返す / 保持する /cellに入れる / グローバルに書くTxを非同期にまたがせる(後述のルールで構文的に禁止)
本仕様では、event/sync の本体は同期関数として定義する(推奨ではなく仕様)。
- 非同期処理は
caps.sched.spawn(...)等で開始する - spawn された非同期タスクから state を更新したい場合は **“送信口”**を使う
Tx を async に持ち出せない以上、実用には「後で更新する」手段が必要です。
FiberFX は以下の 2 系統を用意します(どちらも Fx Zone でのみ呼べる):
namespace use {
pure fn dispatch<T>(s: State<T>): Dispatcher<T>;
}
type Dispatcher<T> = {
// どちらも “新しい Tx” を内部で作って更新を enqueue する
set(next: T): void;
update(f: pure fn(prev: In<T>) -> T): void;
};type Sender<Msg> = { send(msg: Msg): void };これにより「Tx は同期スコープ限定」を守りつつ、通信結果などを後で state に反映できます。
netを渡すと “なんでも叩ける” ため、原則禁止(フレームワーク側のデフォルトで提供しない)- 代わりに 型付き Port を提供する
port UserApi {
fx fn fetchUser(id: string): Promise<User>;
fx fn saveUser(u: User): Promise<void>;
fx fn logAction(a: { kind: string }): Promise<void>;
}portは capability の一種- Port のメソッドは Fx Zone でしか呼べない(Pure Zone からは呼べない)
- OpenAPI 定義から
port相当のクライアント型を生成できる想定 - FiberFX 側は「生成物を Port として登録できる」ことだけ保証する
FiberFX.createRoot(domNode, <App />, {
ports: {
UserApi: createUserApiFromOpenApi(/* ... */),
}
});component Profile(props: In<{ id: string }>)
state { user: User | null = init(null) }
uses { UserApi, Scheduler }
{
let userD = use.dispatch(user$);
use.sync(|{ UserApi, Scheduler }, _tx| {
// Tx は同期限定なのでここでは使わず、非同期結果は Dispatcher へ
let cancel = Scheduler.spawn(async () => {
let u = await UserApi.fetchUser(in.props.id);
userD.set(u);
});
return () => cancel.abort();
});
return <pre>{JSON.stringify(in.state.user)}</pre>;
}namespace use {
pure fn reducer<S, Msg>(
init: S | pure fn() -> S,
reduce: pure fn(state: In<S>, msg: Msg) -> S
): [In<S>, Model<S, Msg>];
}
type Model<S, Msg> = opaque {};
type Sender<Msg> = { send(msg: Msg): void };
type Tx = {
// State と同様に Model にも dispatch できる
send<S, Msg>(m: Model<S, Msg>, msg: Msg): void;
};- reducer 本体は 純粋
Msgを定義しておくと、非同期結果もSender.sendで整理できる
pure fn send<S, Msg>(m: Model<S, Msg>, msg: Msg): EventHandler;使用例:
enum Msg { Inc, Dec }
component Counter(props: In<{}>)
state {
[count, counter$]: reducer<number, Msg>(0, (s, m) => m === Msg.Inc ? s+1 : s-1);
}
{
return (
<>
<button onClick={send(counter$, Msg.Dec)}>-</button>
<span>{in.state.count}</span>
<button onClick={send(counter$, Msg.Inc)}>+</button>
</>
);
}小さい状態は
use.state、大きい状態はuse.reducerを選べる。
namespace use {
pure fn cell<T>(init: T): Cell<T>;
}
type Cell<T> = {
// Fx Zone でのみ可能
get(): T;
set(v: T): void;
};namespace use {
pure fn nodeRef<E extends HTMLElement>(): NodeRef<E>;
}
type NodeRef<E> = opaque {};
capability Dom {
fx fn focus<E extends HTMLElement>(r: NodeRef<E>): void;
fx fn get<E extends HTMLElement>(r: NodeRef<E>): E | null;
}flowchart LR
A[Trigger: event/sync enqueues updates] --> B[Schedule]
B --> C[Render Phase (Pure)\ncomponent runs with In<T> snapshots]
C --> D[Commit Phase\nDOM apply]
D --> E[Layout Sync Phase\nuse.layoutSync]
E --> F[Paint]
F --> G[Passive Sync Phase\nuse.sync]
- Render Phase は 中断・再実行可能(pure なので安全)
- 副作用は Commit 以降にのみ実行(event はユーザー入力起点)
use.layoutSyncは DOM 反映後、paint 前use.syncは paint 後(必要なら非同期キューで実行)
component Bad(props: In<{}>) {
Date.now(); // ❌ ambient time は存在しない(Time capability 経由のみ)
return <div />;
}component Bad(props: In<{}>) {
let c = use.cell(0);
let x = c.get(); // ❌ get は Fx Zone のみ
return <div />;
}<button onClick={event |_, { UserApi }, tx| {
Dom.focus(btnRef); // ❌ Dom を要求していない
}} />“完全な仕様”に近づけるには、ここを確定させる必要があります(本ドラフトでは方針のみ明記)。
-
read-set の丸め規則の詳細
- 動的キー、配列走査、スプレッド、unknown pure 関数呼び出し時の依存の丸め方
-
Object.is以外の比較戦略- 永続データ構造前提なら参照比較で良い
- そうでない場合の “過剰再同期” 許容度
-
Scheduler/Cancel の標準化
spawnの戻り値(AbortController 互換か、独自 token か)- cleanup とキャンセルの相互作用
-
Port のスコープ(グローバル注入 / サブツリー override)
- テストやモック差し替えをどうするか(
providePort的な仕組み)
- テストやモック差し替えをどうするか(
この仕様を「実装可能なレベル」に落とすために、まず選びたいのは次のどれです:
- read-set の丸め規則を具体例で確定(最優先:deps の“事故”に直結)
- 非同期パターンの標準形(Port 呼び出し → 結果反映、キャンセル、競合解決)
use.reducerの Msg 設計(ネストした Model、子コンポーネントへの Msg 伝播など)
希望の順番を指定してくれれば、その章をさらに “仕様として曖昧さが残らない” 形まで掘り下げて追記します。
以下は TSX-FX / FiberFX(ドラフト v0.1) 前提の“擬似コード”サンプルです(※構文は本仕様の
component/event/use.sync/ capability destructuring /Tx同期スコープ限定 / Port 利用 を意識して書いています)。サンプル0: Port(OpenAPI 生成物を想定)と Root 注入
サンプル1: Counter(pure render + event 更新 + title 同期 + nodeRef focus)
サンプル2: Profile(Port + async + Txは同期限定 → Dispatcher で反映、キャンセル付き)
サンプル3: Todo(大きい状態は reducer を“任意で”使用 + 永続化Port)
サンプル4: 命令的UIライブラリ統合(nodeRef + cell + layoutSync を分割してdeps制御)
サンプル5: 外部イベント購読(syncOnce + cell で stale closure 回避)
必要なら次に、サンプルを追加で作れます(例えば 「フォーム送信(Port)+楽観UI+競合解決」、「Context で Port を差し替えてテストする」、**「複数 effect の deps がパス単位でどう導出されるか」**など)。どの系の例が欲しいですか?