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/.
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
| From | To | How |
|---|---|---|
normal | collapsed | collapseLeaf() |
collapsed | normal | restoreLeaf() |
normal | zoomed | zoomLeaf() |
zoomed | normal | unzoomLeaf() |
normal | popped-out | setPoppedOut(…, true) |
popped-out | normal | setPoppedOut(…, false) |
All other direct transitions are illegal — the leaf must pass through normal first:
| Illegal transition | What to do instead |
|---|---|
collapsed → zoomed | restoreLeaf then zoomLeaf |
zoomed → collapsed | unzoomLeaf then collapseLeaf |
collapsed → popped-out | restoreLeaf then setPoppedOut(…, true) |
popped-out → collapsed | setPoppedOut(…, false) then collapseLeaf |
zoomed → popped-out | unzoomLeaf then setPoppedOut(…, true) |
popped-out → zoomed | setPoppedOut(…, 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
Single-zoom invariant. At most one leaf in the entire frameset tree may be in
zoomedstate simultaneously.zoomLeaf()enforces this by restoring any other zoomed leaf tonormalbefore zooming the target.validateFramesetTreerejects trees with multiple zoomed leaves.Normal-as-hub invariant. No direct transition exists between
collapsed,zoomed, andpopped-out. Every such pair must route throughnormal. This keeps the state space small and every transition bi-directional.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 singleempty-leafnode reusing the removedframeId(so callers keep consistent references).Pop-out slot-reservation invariant. A
popped-outleaf 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 theconsumesbus.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
ConsumesBuscreated bycreateConsumesBus()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): ReactNodeInternally 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
| Concern | Old model | Frameset model | ||
|---|---|---|---|---|
| Layout shape | Hard-coded SplitEditorView component with fixed left/right slots | Recursive FramesetTree — any leaf count, any nesting depth | ||
| Scroll sync | Global useScrollSyncPairs hook with a manually maintained pair table | consumes bus: preview subscribes activeScrollTop; the active-producer rule follows focus automatically | ||
| Active file tracking | Ad-hoc shared refs between EditorPane and PreviewPane | "file-tree:active-file" consumes key; any provider can publish or consume it | ||
| Adding a new panel type | Bespoke slot in the layout component | Register a ViewProvider, get split/zoom/pop-out for free | ||
| Persistence | Layout hard-coded in JSX; no serialization | FramesetTree serializes to JsonValue; each provider serializes its own props | ||
| Focus / active producer | Implicit: whichever editor had focus last, informally | Explicit: becomeActive() + the consumes bus enforce the "most-recently-focused wins" rule | ||
| Singleton enforcement | None (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
FramesetTreemetadata. The contract does not prescribe this; W3 should pick one and document it.getAllsubscribe variant:getAllis a point-in-time snapshot. If a consumer wants to react to any-producer changes (not just the active producer), it needs asubscribeAllvariant. Deferred — wait for a real use case before adding API surface.FramesetMountPayloadin the shell: The payload type is defined; the shell needs to wire it into its mount/context setup (W3.1 or W3.2). The initialinitialActiveProducerIdshould 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.