zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Frameset Architecture

W2.1 contract-confirm gate for the Frameset Architecture epic (#1419). This document defines the stable contract that Waves 3–9 implement against. Read architecture/philosophy first for the broader design framing.

The Frameset Architecture replaces the old hard-coded split-editor-view / scroll-sync-pair model with a declarative, config-driven layout system. Any panel-style view can be registered as a ViewProvider and placed in any leaf of a recursive FramesetTree. The tree is persisted as JsonValue and round-tripped through serialize / deserialize — views do not own their layout.

Core types — @takazudo/view-provider

All types are exported from packages/view-provider/src/index.ts.

ConsumeKey

type ConsumeKey = string;

An opaque string identifying a piece of cross-frame data. Convention: "<provider-id>:<channel>", e.g. "file-tree:active-file". Providers that write and read a value must agree on the exact string — there is no compile-time link.

JsonValue

type JsonValue =
  | null | boolean | number | string
  | JsonValue[]
  | { [key: string]: JsonValue };

The serializable form of provider props. Props travel through persistence and IPC as JsonValue; ViewProvider.serialize / deserialize handle the round-trip.

FrameState

type FrameState = "normal" | "collapsed" | "zoomed" | "popped-out";

Mutually exclusive lifecycle state of a leaf. A leaf is in exactly one state at any given time.

FrameState transition table

FromToHow
normalcollapsedcollapseLeaf()
collapsednormalrestoreLeaf()
normalzoomedzoomLeaf()
zoomednormalunzoomLeaf()
normalpopped-outsetPoppedOut(…, true)
popped-outnormalsetPoppedOut(…, false)

All other direct transitions are illegal — the leaf must pass through normal first:

Illegal transitionWhat to do instead
collapsedzoomedrestoreLeaf then zoomLeaf
zoomedcollapsedunzoomLeaf then collapseLeaf
collapsedpopped-outrestoreLeaf then setPoppedOut(…, true)
popped-outcollapsedsetPoppedOut(…, false) then collapseLeaf
zoomedpopped-outunzoomLeaf then setPoppedOut(…, true)
popped-outzoomedsetPoppedOut(…, false) then zoomLeaf

The helpers in frameset-tree.ts (collapseLeaf, zoomLeaf, etc.) throw an Error for illegal transitions so violations surface at call time, not silently.

Four state-machine invariants

  1. Single-zoom invariant. At most one leaf in the entire frameset tree may be in zoomed state simultaneously. zoomLeaf() enforces this by restoring any other zoomed leaf to normal before zooming the target. validateFramesetTree rejects trees with multiple zoomed leaves.

  2. Normal-as-hub invariant. No direct transition exists between collapsed, zoomed, and popped-out. Every such pair must route through normal. This keeps the state space small and every transition bi-directional.

  3. Last-leaf invariant. A frameset tree always has at least one leaf. removeLeaf() enforces this: if removing a leaf would empty the tree, it collapses to a single empty-leaf node reusing the removed frameId (so callers keep consistent references).

  4. Pop-out slot-reservation invariant. A popped-out leaf is NOT removed from the tree — its slot is reserved and the shell renders a collapsed placeholder in-place. This preserves the branch topology so dock-back (W9.1) can return the leaf to its original position without rebuilding the tree.

FramesetTree

type FramesetTree = SplitNode | LeafNode | EmptyLeafNode;

A recursive discriminated union. Every consumer that switches on tree.type must cover all three cases; use assertNever(node) in the default branch so adding a fourth kind becomes a compile error.

interface SplitNode {
  readonly type: "split";
  readonly branchId: string;           // unique within the tree
  readonly direction: "horizontal" | "vertical";
  readonly ratio: number;              // [0, 100] — percentage for `first`
  readonly first: FramesetTree;
  readonly second: FramesetTree;
}

interface LeafNode {
  readonly type: "leaf";
  readonly frameId: string;            // unique within the tree
  readonly instanceId: string;         // survives provider swaps, pop-out/dock-back
  readonly providerId: string;
  readonly props: JsonValue;
  readonly state: FrameState;
  readonly title?: string;
}

interface EmptyLeafNode {
  readonly type: "empty-leaf";
  readonly frameId: string;            // unique within the tree
  readonly state: FrameState;
}

branchId, frameId, and instanceId must all be globally unique within a tree. validateFramesetTree() rejects duplicates.

ViewProvider<TProps>

interface ViewProvider<TProps = JsonValue> {
  readonly id: string;
  readonly title: string;
  readonly description: string;
  readonly icon: ReactNode;
  render(props: TProps, ctx: FrameContext): ReactNode;
  serialize(props: TProps): JsonValue;
  deserialize(json: JsonValue): TProps;
  readonly defaultProps: TProps;
  readonly singletonScope: "app" | "frameset" | "none";
  readonly preferredOpenSplit: SplitDirection;
  readonly consumes?: ReadonlyArray<ConsumeKey>;
  readonly canPopOut?: boolean;
  canClose?(props: TProps): CanCloseResult | Promise<CanCloseResult>;
  getDirtyState?(props: TProps): boolean;
  readonly acceptsFileDrop?: AcceptsFileDrop<TProps>;
  handleFileDrop?(file: File, props: TProps, ctx: FrameContext): void | Promise<void>;
}

Providers are stateless w.r.t. the framework. Long-lived state lives in props and is round-tripped through serialize / deserialize. Providers must not cache state in closure variables across render calls.

FrameContext

The single seam through which a provider talks to the surrounding shell. The shell creates one FrameContext per mounted leaf and tears it down when the leaf is closed.

interface FrameContext {
  readonly frameId: string;
  readonly framesetId: string;
  readonly instanceId: string;

  // DOM focus — does NOT affect active-producer ownership.
  focus(): void;

  // Claim active-producer slot for this frame's published keys.
  // Call this in onFocus handlers (for editors) and standalone for
  // background producers that publish without receiving DOM focus.
  becomeActive(): void;

  requestClose(): void;
  requestSplit(direction: SplitDirection): void;
  requestZoom(): void;
  requestPopOut(): void;

  getActive<T>(key: ConsumeKey): T | null;
  getAll<T>(key: ConsumeKey): Array<{ frameId: string; value: T | null }>;
  subscribe<T>(key: ConsumeKey, cb: (val: T | null) => void): Unsubscribe;
}

focus() vs becomeActive()

These are separate methods because DOM focus and active-producer ownership are orthogonal concerns (PoC W1.3 finding #5):

  • focus() — moves keyboard focus to this leaf at the window/shell level. Never touches the consumes bus.

  • becomeActive() — claims the active-producer slot so consumers subscribed to keys this frame publishes immediately see its latest values.

The shell should wire onFocus → focus() + becomeActive() for ordinary editors. Background producers (e.g. a file-watcher panel that publishes "current selected file" but never receives tab-focus) call only becomeActive().

subscribe — fire-on-subscribe contract

subscribe(key, cb) fires cb synchronously with the current value (or null) on the same call stack as subscribe(), before the returned Unsubscribe handle is available. This is a first-class contract guarantee, not an implementation detail. Consumers MUST NOT call getActive separately after subscribing to bootstrap — the initial fire handles it.

getAll — escape hatch

getAll<T>(key) returns a snapshot of every producer's latest value for key, keyed by frameId. It returns an empty array when nothing has been published. This is an escape hatch for the rare consumer that needs to render all producers simultaneously (e.g. a mini-map across all open editors). Most consumers should use getActive / subscribe instead.

FramesetMountPayload

interface FramesetMountPayload {
  readonly framesetId: string;
  readonly initialActiveProducerId?: string;
}

Provided by the shell when a frameset mounts. initialActiveProducerId names the leaf that should own the active-producer slot before any user interaction (e.g. the left-most editor in a new window). Without this, the active slot would be filled by "first publisher wins" — an ordering that depends on React's useEffect mount order and is fragile (PoC W1.3 finding #1).

consumes data-flow

The consumes model is how providers share data across frames without coupling to each other's render cycles.

  • Producers call publish(key, value, frameId) whenever they have new data (e.g. the file-tree publishes the active file path under "file-tree:active-file").

  • Consumers call subscribe(key, cb) to receive the current value and every subsequent change.

  • The bus is a ConsumesBus created by createConsumesBus() and shared across all providers in a frameset.

Active-producer model

The shell's frameset orchestrator layers an active-producer concept on top of the ConsumesBus:

  • Each frame that publishes to a key is a producer for that key.

  • Only the active producer's value drives consumer callbacks.

  • When becomeActive() is called on a frame, that frame becomes active for every key it has published into. Consumers immediately receive the new active producer's latest value.

This is how a MarkdownPreview "follows" the most-recently-focused editor without knowing which editors exist.

Cycle-break semantics — first-write wins

If a subscriber callback runs as a side-effect of a publish higher on the call stack and tries to publish to the same key again, the re-entrant publish is dropped and console.warn is called. This is "first-write wins, second-write logs warning."

A cross-key re-entrant publish (A → subscriber → publish B) is not a cycle and is allowed — B is a different key.

The runtime cycle-break handles emergent cycles (topology discovered at runtime). detectConsumesCycles(graph) handles static cycles declared in provider.consumes. Both layers are needed because static declarations only cover cycles inferable from <provider-id>:<channel> keys.

Watch for accidental two-way edges: "preview consumes activeDraft" + "draft consumes previewScrollTop" feels innocent but creates update storms.

Typed render via ProviderRegistry.renderLeaf

The ProviderRegistry exposes a typed render entry point:

registry.renderLeaf(leaf: { providerId: string; props: JsonValue }, ctx: FrameContext): ReactNode

Internally this calls provider.deserialize(leaf.props) to obtain TProps, then provider.render(typedProps, ctx). The JsonValue → TProps cast happens exactly once, inside the registry, rather than at every call-site in the shell (PoC W1.3 finding #7). Returns null when no provider is registered for providerId — the shell should render an error placeholder.

Contrasts with the old per-panel placement model

ConcernOld modelFrameset model
Layout shapeHard-coded SplitEditorView component with fixed left/right slotsRecursive FramesetTree — any leaf count, any nesting depth
Scroll syncGlobal useScrollSyncPairs hook with a manually maintained pair tableconsumes bus: preview subscribes activeScrollTop; the active-producer rule follows focus automatically
Active file trackingAd-hoc shared refs between EditorPane and PreviewPane"file-tree:active-file" consumes key; any provider can publish or consume it
Adding a new panel typeBespoke slot in the layout componentRegister a ViewProvider, get split/zoom/pop-out for free
PersistenceLayout hard-coded in JSX; no serializationFramesetTree serializes to JsonValue; each provider serializes its own props
Focus / active producerImplicit: whichever editor had focus last, informallyExplicit: becomeActive() + the consumes bus enforce the "most-recently-focused wins" rule
Singleton enforcementNone (could open two terminals by accident)`ViewProvider.singletonScope: "app""frameset""none"` — shell enforces at placement time

The new model unifies all panel-style views under one contract, so features like zoom, collapse, pop-out, and file-drop work uniformly without per-view special cases.

Contract changes from PoC W1.3 findings

The PoC identified seven findings. Here is each one's disposition:

Finding #1 — Initial active-producer state must be explicit

Applied. Added FramesetMountPayload.initialActiveProducerId. The shell passes this when mounting a frameset so the first active producer is deterministic, not mount-order-dependent. This directly eliminates the "first publisher wins" fragility flagged in the PoC.

Finding #2 — Document subscribe's fire-on-subscribe semantics in the type contract

Applied. Added an explicit JSDoc block to FrameContext.subscribe and to ConsumesBus.subscribe stating that the fire-on-subscribe behavior is a first-class contract guarantee. Updated the implementation comment in createConsumesBus to reinforce this. Added a dedicated test (subscribe fire-on-subscribe is a contract guarantee) to make regressions visible.

Finding #3 — Deterministic fallback rule when the active producer unmounts (LRU-of-recently-active)

Partially applied — documented, not implemented. The ConsumesBus at W1.2 is a single-value-per-key bus; multi-producer fallback is a shell-level concern (the shell's frameset bus tracks per-frame producers). The LRU rule is the correct policy when the shell implements unregisterFrame. It is not implemented in ConsumesBus itself because ConsumesBus.publish is keyed by ConsumeKey alone (the frameId is an optional tagging parameter for getAll), and the active-producer selection is a higher-level shell responsibility. The finding is noted here for the W3 shell implementer.

Finding #4 — Add getAll<T>(key) for any-producer consumers

Applied. Added getAll<T>(key) to ConsumesBus, FrameContext, and their implementations. The publish method gained an optional frameId parameter so per-producer values can be tracked. getAll returns a snapshot keyed by frameId. Added tests in consumes.test.ts.

Finding #5 — Split ctx.focus() (DOM focus) from ctx.becomeActive() (active-producer claim)

Applied. FrameContext.focus() is now documented as a pure DOM focus call that never touches the consumes bus. FrameContext.becomeActive() is the new method that claims the active-producer slot. The PoC stand-in (frameset-poc-providers.tsx) was updated to call both in onFocus, and makeFrameContext was updated to implement both. This is a breaking change to the FrameContext interface — all implementors must add becomeActive().

Finding #6 — Watch for accidental two-way edges in AI-suggestions / draft pair

No contract change — this is a usage guideline, not a contract gap. Documented in the cycle-break section above. W1.2's detectConsumesCycles and createConsumesBus cycle-break are the mechanisms; W3+ implementors should run detectConsumesCycles when registering providers.

Finding #7 — Registry should expose a typed render API

Applied. Added ProviderRegistry.renderLeaf(leaf, ctx) which calls deserialize + render internally. The JsonValue → TProps cast happens once, inside the registry. Added makeCtx stub and two tests to registry.test.ts.

Open questions for W3+

  • LRU fallback (Finding #3): The shell needs to decide where to track the recently-active stack — in the frameset-level bus, in React state, or in persisted FramesetTree metadata. The contract does not prescribe this; W3 should pick one and document it.

  • getAll subscribe variant: getAll is a point-in-time snapshot. If a consumer wants to react to any-producer changes (not just the active producer), it needs a subscribeAll variant. Deferred — wait for a real use case before adding API surface.

  • FramesetMountPayload in the shell: The payload type is defined; the shell needs to wire it into its mount/context setup (W3.1 or W3.2). The initial initialActiveProducerId should come from the persisted layout's "last active frame" field.

  • becomeActive() and singleton scope: When a singleton (singletonScope: "app" or "frameset") is the active producer and the user opens a second window, which frameset's leaf is "active"? The protocol for cross-frameset active-producer ownership is out of scope for this wave — flag for W5.1.