zudo-text

検索したい単語を入力

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

Frameset–Pin Contract

Design-decision record for the frameset/pin matching, draft-divergence, live- tree selector, pristine-snapshot rollback, and debounced-save flush subsystem introduced in Epic #1744 (Wave 5, sub #1757). Implementors must treat this document as normative: if code disagrees with the spec here, the spec wins.

Warning

This document is the deliverable for sub-issue #1757 (Wave 5 of Epic #1744, Frameset Pin Architecture). It records the ten design decisions (D1–D10) introduced across Waves 1–5 of #1744.

Historical note: D2, D3, D4, D8, and D9 describe v1 concepts superseded by the v2 singleton-frameset model (Epic #1961). Those sections are preserved as historical record and carry individual :::caution callouts. D1, D5, D10 remain relevant and normative. The authoritative v2 description is inFrame / Frameset / Pin / Provider Instance Cache.

See also: Frame Component Contractfor the broader provider / leaf-state / toolbar contract that this document extends.

Why this contract exists

The Frameset Pin Architecture epic (#1744) replaced the previous ad-hoc frameset switching with a principled system: every saved frameset is a named, identity-comparable snapshot, and the live UI tree is continuously matched against that set. When the live tree diverges from all saved framesets, the app enters a "draft" mode rather than silently corrupting the saved copy. This document captures the ten design decisions that make that system correct and deterministic.


D1 — Matcher equivalence rule

Two frameset trees are equivalent iff they share the same structure shape, the same providerId at every leaf, and the same matchIdentity(props) value at every leaf. Split ratio, frameId, instanceId, and per-leaf state are NOT compared.

Implementation

framesetsEquivalent in packages/frameset/src/matcher.ts recursively canonicalises both trees via canonicalize, then JSON-stringifies and compares the result. canonicalize descends the tree and at each leaf node calls:

registry.getProvider(leaf.providerId)?.matchIdentity(leaf.props)
  ?? defaultMatchIdentity(leaf.props)

where defaultMatchIdentity returns { layoutId: props.layoutId ?? null } from raw JsonValue.

Provider matchIdentity table

Provider idmatchIdentity return valueMeaning
core.inbox{ directory }Same Note Tray directory = same identity (epic #3426). layoutId lives in the instance cache, not serialized props, so it cannot be part of this identity; without the directory key the Inbox and Archives pins (both core.inbox, differing only by directory) would alias on the empty default identity and the Archives pin would never highlight as active.
core.empty{ layoutId } (default)Same layout = same identity
core.kanban-board{ layoutId, boardPath }Same .md file + layout
core.mindmap-board{ layoutId, boardPath }Same .md file + layout
core.todo-board{ layoutId, boardPath }Same .md file + layout
core.external-file-editor{ layoutId, paths, treeRoot }Sorted set of open local paths + layout + embedded-tree root; collapse is presentation only
core.doc-cloud{ projectSlug, initialSurface, editorLayout }Project/navigation/editor-layout seed; embedded-outline collapse is presentation only

core.external-file-editor uses a sorted set of open paths so that opening the same files in a different order still produces a match. "Opening a different file" means a different set of open paths — adding or removing even one file produces a distinct identity.

What is NOT compared

The following differences do NOT cause divergence:

  • Split ratio — resizing a divider between panes is cosmetic.

  • frameId — generated per tree-restructure; carries no semantic identity.

  • instanceId — survives provider swap and pop-out; not a structural key.

  • Leaf state ("normal" / "collapsed" / "zoomed" / "popped-out") — visibility variants of the same provider instance.

  • Embedded-pane collapse — EFE treeCollapsed and Doc Cloud outlineCollapsed persist as presentation state but never change pin identity.

Defining matchIdentity on a new provider

Add matchIdentity(props: JsonValue): unknown to the provider object. Return a JSON-comparable value whose equality implies "same logical content". The returned value is passed through JSON.stringify and compared as a string, so nested objects and arrays are fine — just ensure order stability for arrays (sort them). If your provider does not declare matchIdentity, the default { layoutId } identity is used automatically.


D2 — Draft frameset slot

Caution

The draftFrameset slot and originFramesetId breadcrumb described here are v1 design decisions that no longer exist in the codebase. The v2 singleton-frameset model eliminates both: AppSettings.frameset always holds the live tree directly, and draft mode is a purely-derived UI state (activePinId === null). The authoritative description of the temp-pin mechanism is in Frameset and Pin Architecture — "Temp pin" section.

This section is preserved as a historical record.

AppSettings.draftFrameset is an ephemeral field that is present when and only when the live tree has diverged from all saved framesets. currentFramesetId ALWAYS resolves to a real saved-frameset id — there is no "draft" sentinel value for that field.

Type signatures

// packages/app-defaults/src/types.ts

interface AppSettings {
  // …other fields…

  /** Id of the currently-active saved frameset. */
  currentFramesetId: string;

  /**
   * Present when the live tree has diverged from every saved frameset.
   * Absence (undefined) means the live tree matches currentFramesetId exactly.
   * Design decision D2 (#1744): currentFramesetId ALWAYS resolves to a saved
   * frameset — "user is on a draft" is expressed only via this field's presence.
   */
  draftFrameset?: {
    tree: FramesetTree;
    activeFrameId?: string;
    /** The saved frameset the user was on when they first diverged. */
    originFramesetId: string;
    createdAt: number;
    updatedAt: number;
  };
}

Lifecycle

EventcurrentFramesetIddraftFrameset
App loads, tree matches saved framesetSaved frameset idundefined
User resizes a pane / switches layoutUnchangedSet (draft created)
Live tree re-matches a saved framesetUpdated to matched idCleared
User promotes draft to a new pinNew frameset idCleared
User discards draftRestored to originFramesetIdCleared

The draft's originFramesetId records which saved frameset the user was on immediately before they first diverged. Discard restores currentFramesetId to that value.


D3 — Live-tree selector

Caution

getActiveFramesetView and frameset-selectors.ts no longer exist in the codebase. In v2 there is no framesets[] array or currentFramesetId — the singleton frameset is read directly from AppSettings.frameset viauseSavedFrameset (singular). This section is preserved as a historical record. The authoritative v2 description is inFrameset and Pin Architecture.

All consumers of the current frameset tree MUST read from getActiveFramesetView (tauri-app/renderer/lib/frameset-selectors.ts). Direct indexing of framesets[] by currentFramesetId is forbidden.

ActiveFramesetView interface

interface ActiveFramesetView {
  /** The tree to render. Draft tree when isDraft; saved tree otherwise. */
  tree: FramesetTree;
  /** Active frame within the tree (if known). */
  activeFrameId?: string;
  /** True when the live tree is a draft (diverged from all saved framesets). */
  isDraft: boolean;
  /** Id of the saved frameset this view is logically tied to. */
  savedFramesetId: string;
  /** Present only when isDraft; the saved frameset the user diverged from. */
  originFramesetId?: string;
}

getActiveFramesetView contract

function getActiveFramesetView(
  framesets: Frameset[],
  currentFramesetId: string,
  draftFrameset?: DraftFrameset,
): ActiveFramesetView | null
  • Returns null only when framesets is empty (app is initialising).

  • Falls back to the inbox frameset or framesets[0] when currentFramesetId is not found (e.g. deleted frameset id still in settings).

  • When draftFrameset is present, tree is draftFrameset.tree and isDraft = true; otherwise tree is the matching saved frameset's tree.

  • WeakMap-cached. The selector memoises on (framesets, currentFramesetId, draftFrameset) identity so callers do not re-render from reference churn.

Why not index directly

framesets is an array. Finding the active frameset by currentFramesetId is a linear scan. More importantly: during the brief window after a draft is promoted or discarded, the live currentFramesetId may not yet match any entry in the serialised framesets[] snapshot the component sees. getActiveFramesetView handles these edge cases uniformly; ad-hoc indexing at every call site re-creates the same bugs.


D4 — Pin highlight derivation

Caution

The derivation rule described here (pin.framesetId === currentFramesetId + !draftFrameset) is v1 logic that no longer applies. In v2, there is no currentFramesetId or draftFrameset field. Highlight state is derived solely from findActivePinId(liveTree, savedPins, registry): a pin is highlighted iff the live singleton frameset tree is equivalent to that pin's template under the same D1 equivalence rule used by frameset persistence — structure + providerId + matchIdentity(props) (#2787). findActivePinId delegates to framesetsEquivalent rather than re-implementing a separate structure-only fingerprint, so identity-distinct pins (different boardPath, different layoutId) no longer collide on highlight. The authoritative description is in Frameset and Pin Architecture — "Pin highlighting" and "Temp pin" sections.

This section is preserved as a historical record.

Pin highlight state is a pure derivation — it is never stored. A pin is highlighted iff the live tree currently matches the pin's saved frameset AND no draft is active.

Derivation rule (pseudocode)

const isHighlighted =
  !draftFrameset &&
  pin.framesetId === currentFramesetId &&
  useCurrentMatchingFramesetId() === pin.framesetId;

The double guard is necessary:

  1. pin.framesetId === currentFramesetId — the pin points to the currently-selected saved frameset.

  2. useCurrentMatchingFramesetId() === pin.framesetId — the live tree currently matches that saved frameset (not some other saved frameset the user navigated to mid-draft).

Without guard 2, a pin would stay highlighted even when the user navigates away but has not yet diverged. Without guard 1, the wrong pin would highlight when the user happens to match a non-current frameset.

Temp pin

When draftFrameset is present, no saved pin is highlighted. Instead, a temp pin (TempPin component in toolbar.tsx) appears with a LayoutGrid icon in the left cluster. It represents the unsaved diverged tree. See Temp pin UX in the user guide for the interaction details.


D8 — Pristine snapshot rollback

Caution

pristineTreeByFramesetIdRef, draftFrameset, and useSavedFramesets (plural)no longer exist in the codebase. Epic #1961 replaced the multi-frameset model with a singleton: AppSettings.frameset holds one FramesetTree directly, anduseSavedFrameset (singular) manages it. The pristine-snapshot rollback mechanism described here was removed with the v1 draft concept. This section is preserved as a historical record. The authoritative v2 description is inFrameset and Pin Architecture.

Each saved frameset has a pristine baseline snapshot, seeded from the on-disk tree at hook load. When the live tree diverges from a saved frameset, that frameset's stored tree is rolled back to its pristine baseline — preventing the saved frameset from silently tracking in-flight mutations.

Implementation

useSavedFramesets (tauri-app/renderer/hooks/use-saved-framesets.ts) maintains:

const pristineTreeByFramesetIdRef = useRef<Map<string, FramesetTree>>(new Map());

The map is seeded once, when the hook first loads framesets from disk. It is never updated after that seed (unless a frameset is created, at which point its initial tree is added).

When handleLiveTreeChange detects divergence from frameset F:

  1. It reads pristineTreeByFramesetIdRef.current.get(F.id) to get the baseline.

  2. It writes the baseline tree back into F's stored entry in settings.

  3. It sets draftFrameset to the current live tree.

This ensures that if the user discards the draft, the saved frameset is the same shape it had when the user last explicitly saved it — not some intermediate state the live tree happened to pass through.

Why the name "pristine"

"Pristine" = untouched by in-flight mutations. The baseline is the snapshot from disk, before any live-tree changes. Contrast with "saved" (which in casual speech could mean "last-written-to-disk", including intermediate writes the hook does for other reasons).


D9 — Debounced-save flush contract

Caution

promoteDraftToPin, discardDraft, and flushPendingSave no longer exist in the codebase. These functions were part of the v1 draft/frameset model removed by Epic #1961. In v2 there is no draft state — the singleton frameset is updated directly via useSavedFrameset (singular). This section is preserved as a historical record of the v1 flush-before-write safety contract.

promoteDraftToPin and discardDraft MUST call flushPendingSave() before writing any new settings state. Skipping the flush can cause a debounced save to overwrite the newly-written state.

Context

useSavedFramesets debounces settings writes: live-tree changes queue a save that fires 300 ms after the last change. promoteDraftToPin and discardDraft also write settings. If the debounced timer fires between the point where the action reads the current state and the point where it writes, the debounced write overwrites the action's result.

flushPendingSave() cancels the pending timer and executes the save immediately, so the state the action reads is the same state on disk when it writes.

Call order

async function promoteDraftToPin(opts) {
  flushPendingSave();           // ← must be first
  // … build new Frameset + HeaderLeftPin …
  await writeSettings(next);    // ← safe: no pending timer can race
  await validateSettings(next);
  await syncToDisk(next);
}

async function discardDraft(opts) {
  flushPendingSave();           // ← must be first
  // … restore originFramesetId, clear draft …
  await writeSettings(next);
}

Any future action that reads-then-writes settings within a component that owns a debounced save MUST follow the same pattern.


D10 — Layout-change divergence routing

Layout-change events from useInboxLayoutPersistence and useArchivesLayoutPersistence route through handleLiveTreeChange rather than calling updateFramesetTree directly. This ensures that switching layouts can trigger draft mode when the new layout does not match any saved frameset.

Why this matters

Layout persistence hooks listen for FRAME_LAYOUT_CHANGE_EVENT and update the leaf's layoutId prop inside the live tree. Before D10, they called updateFramesetTree directly — bypassing the matcher. This meant that switching from "list" to "now" on the inbox never produced a draft, even if no saved frameset had layoutId: "now" at that leaf.

After D10, every live-tree mutation (resize, layout switch, provider swap) routes through handleLiveTreeChange. The matcher runs on every mutation and diverges the tree into draft mode if needed.

Consequence for new layout-aware providers

Any provider that emits layout changes (via FRAME_LAYOUT_CHANGE_EVENT or equivalent) MUST wire its persistence hook to handleLiveTreeChange. Calling updateFramesetTree directly is forbidden — it bypasses the matcher and prevents draft mode from triggering correctly.


D5 — Pin providerProps schema

HeaderLeftPin.providerProps is a typed per-provider map. New provider-specific providerProps keys MUST be added to PinProviderPropsByProviderId in packages/app-defaults/src/types.ts.

Schema table

PinProviderPropsByProviderId in @takazudo/app-defaults maps each provider id to its known providerProps fields:

Provider idproviderProps keysMeaning
core.todo-boardinitialBoardPath?: stringPath to the backing .md file, promoted to filePath on first mount
core.kanban-boardinitialBoardPath?: stringSame, for kanban
core.mindmap-boardinitialBoardPath?: stringSame, for mindmap
core.external-file-editorinitialFiles?: string[]; initialTreeRoot?: string; treeCollapsed?: booleanLocal-absolute files and tree root; the runtime leaf converts these to openPaths / treeRoot
core.doc-cloudprojectSlug?: string | null; initialSurface?: "projects" | "overview" | "activity" | "editor"; editorLayout?: "edit" | "preview" | "split"; outlineCollapsed?: booleanSafe one-frame navigation/layout seeds only; never source, tabs, guards, fetched state, or credentials
(all others)No providerProps keys registered

Doc Cloud pins always contain one core.doc-cloud leaf. The outline is an embedded pane, never a second provider or a two-leaf template.

The old standalone Directory View provider is a deliberate pre-release break: there is no migration for pins or trees that still name it. Validation may preserve an opaque old pin, which can render provider-not-found until the user removes it or resets settings; no local file content is changed. The retired id must not be registered as a compatibility provider.

Promotion semantics for initialBoardPath

todoBoardProvider.deserialize (and the kanban / mindmap equivalents) checks whether base.filePath === "" and the serialised JSON carries a non-empty initialBoardPath. If so, it promotes the pin field to filePath on first deserialise. This allows pins to encode a target file path without the provider needing to understand how pins work.

ROOT generate-step settings isolation

Pin providerProps are not configurable from the ROOT's generate-child settings step (Step 2 of the generation flow). The shared settings sections in packages/settings-sections/src/sections/ contain neither terminal settings nor pin editors. Pin providerProps are configured per-pin inside the generated LEAF app's Manage header pins dialog, not at generation time.


Watch-for-next-time patterns

These patterns are known footguns. They appear here so the next implementor does not rediscover them.

Provider matchIdentity with arrays — sort them

If matchIdentity includes an array (e.g. the list of open paths in core.external-file-editor), the array must be sorted before returning it. canonicalize uses JSON.stringify for comparison; ["a","b"] and ["b","a"] stringify differently even though they represent the same set. core.external-file-editor sorts openPaths alphabetically before returning { layoutId, paths }.

Do not call updateFramesetTree from layout-persistence hooks

See D10. Every live-tree mutation must go through handleLiveTreeChange. Ad-hoc calls to updateFramesetTree bypass the matcher and silently suppress draft mode.

getActiveFramesetView caches on identity, not value

The WeakMap key is the framesets array reference and the draftFrameset object reference. If a caller recreates these on every render (e.g. via useMemo with too broad a dependency), the cache misses on every call. Ensure framesets comes from a stable store slice and draftFrameset is the same object reference between renders when it has not changed.

flushPendingSave before any read-modify-write on settings

Any action that (1) reads the current settings, (2) does async work, and then (3) writes new settings, MUST call flushPendingSave() before step 1. A pending debounced write that fires between steps 1 and 3 will overwrite step 3's result. See D9.

Draft exists iff draftFrameset !== undefined

Do not test draftFrameset === null — the field is undefined when absent, not null. JSON.parse of a settings file that omits draftFrameset returns an object with the key absent (not null). A null check will pass when it should not.


See also