zudo-text

検索したい単語を入力

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

l-lessons-frame-frameset-pin-model

Canonical lessons for the singleton-frameset, pins-as-templates, and per-provider instance cache model introduced in Epic #1961 (Frameset Arch v2). Use when: (1) Adding a new provider, (2) Adding a ne...

Lessons: Frame / Frameset / Pin / Provider Instance Cache Model

Architecture reference: doc/src/content/docs/architecture/frame-frameset-pin.mdx


Core rule

  • One singleton frameset per app window. No framesets[] array. The live tree is persisted under AppSettings.frameset (singular).

  • Pins are structural templates. No framesetId linkage. Every pin click always rebuilds the singleton tree from the template via applyPin(pin). There is no "if current tree matches, do nothing" bail.

  • State lives in module-scoped instance caches. Not in singleton refs (no shared activeDraftRef). Not in persisted leaf props (mostly). Each cache is keyed by Frameset leaf frameId.

These three rules are not negotiable. Any design that violates one of them must be escalated.


Cache contract by example — inbox

The per-leaf instance caches are plain module-scoped registries in tauri-app/renderer/services/ (inbox-instance-cache.ts, external-file-editor-session-cache.ts, ai-assistant-instance-cache.ts) plus the provider-owned doc-cloud-frame-cache.ts, keyed by leaf frameId. They fix #1955 — InboxCacheEntry.state.selectedDraft is per-leaf, so two inbox frames no longer share one selected draft. inbox-instance-cache.ts also carries a per-leaf directory field (epic #3426, Note Tray) — the same cache and provider now back both the Inbox (directory: "inbox") and Archives (directory: "archives") trays; search-instance-cache.ts and its provider (core.search) were retired.

// Inside each inbox frame's lazy useState initializer / mount useEffect:
const entry = inboxInstanceCache.acquire(frameId);
// First call for this frameId creates a fresh default entry; later calls
// return the same live entry (idempotent, StrictMode-safe).

// Cold-start hydration: a fresh entry has `hydrated: false` and selectedDraft 1.
// The leaf resolves the real active draft once via drafts.getActive().

// When the frame unmounts (not closed — StrictMode / breakpoint flip / swap):
inboxInstanceCache.release(frameId);
// Passive — the entry stays alive for a warm re-mount.

// Stale entries are NOT released by `release`. write-page.tsx's tree-change
// sweep effect calls sweepExcept(liveFrameIds) on every cache so frameIds
// no longer in the live tree (pin click rebuilds with fresh ids; frame close
// drops a leaf) are destroyed. This is the leak guard — never remove it.

Most instance caches are session-scoped — per-leaf state is lost on app quit. Only AppSettings.frameset (the tree structure) and AppSettings.framesetLeafState survive quit. core.inbox is the first (and, as of Epic #1974, the only) across-quit provider: its cache implements snapshot() / hydrateFromPersisted(blob) / subscribe(listener) / notifyChange(), and a debounced persist coordinator (use-inbox-leaf-state-persistence.ts, 300 ms) writes the snapshot to framesetLeafState["core.inbox"] in the same atomic settings.save as frameset. Cold load hydrates the cache before leaves mount (use-saved-frameset.ts). Do not promise across-quit restore for any other provider — adding the tier requires explicit product sign-off and a real AppSettings.framesetLeafState namespace entry backed by the four-method cache contract above.

Per-leaf selectedDraft alone is not enough — content routing must also respect the per-leaf cache. Epic #2002 completed the second half: each pane's content is now resolved through acquire(paneId).state.selectedDraft and the inbox-draft-content-cache subscriber pattern. For the full routing contract (focus-gate, mirror-effect removal, activeDraftRef scope), see the "Inbox content routing" subsection in doc/src/content/docs/architecture/frame-frameset-pin.mdx.

Named-to-numbered reconcile is a refcounted routing gate (#5238)

An Inbox leaf's selectedNamedFile and selectedDraft are a discriminated selection: the numbered draft is only a fallback while a named file is selected. handleDraftChange and handleNewDraft must clear the named discriminant before awaiting their store work, but that makes the fallback temporarily stale. Treat that interval as a cache-level reconcile, never as a usable numbered route:

const token = inboxInstanceCache.beginReconcile(frameId);
try {
  resetNamedSelection();
  const draft = await store.switchDraft(frameId, target);
  // Commit the coherent numbered state, or restore the named state in catch.
} finally {
  // No await between the coherent commit/rollback and this release.
  inboxInstanceCache.endReconcile(token);
  inboxInstanceCache.notifyChange(); // one coherent trailing edge, when needed
}
  • beginReconcile / endReconcile own a private per-entry refcount. Overlapping transitions keep reconcilePending true until all paired tokens finish; callers and subscribers may read the derived flag but must never mutate it.

  • beginReconcile and endReconcile deliberately do not broadcast. The page-level routing subscriber uses reconcilePending as a read-side gate and retains its last stable numbered target until the provider's trailing notifyChange().

  • Always end in finally. Tokens are idempotent and end through a non-acquiring entry-identity check, so a late completion after destroy, copy, or hydrate is a no-op rather than an entry resurrection or a decrement of a replacement with the same frameId.

  • reconcilePending and its refcount are strictly session-ephemeral: exclude them from snapshots, hydration, and copied state. A relaunch or copied leaf must not resume an interrupted selection transition.

This contract matters whenever a change introduces an await between two fields that a subscriber reads together. Keep the final commit/rollback, token release, and trailing notification contiguous; otherwise an unrelated cache broadcast can route the stale fallback to the page-level active draft.


Per-provider state policy table

The "scope" column uses two values: session (lost on quit) and across-quit (snapshotted to AppSettings.framesetLeafState and restored on cold load). Currently core.inbox is the only across-quit provider.

Provider idPer-leaf stateScopeNotes
core.inboxdirectory (re-seeded from provider props), selected draft/named file, view mode, layout id, timeline/grid/search UIacross-quitSelection and UI state are snapshotted to framesetLeafState["core.inbox"] by use-inbox-leaf-state-persistence.ts and hydrated before leaves mount; directory is not persisted. reconcilePending and its refcount are session-only, never snapshotted or copied. Backs both Inbox and Archives (epic #3426 Note Tray); core.search / core.archives-list-view are retired.
core.external-file-editoropen file set, active tab, embedded-tree root/collapse/expansion statesessionRe-opened from a pin's local-absolute providerProps; tree clicks call the containing session. Identity includes sorted paths + tree root, not collapse.
core.doc-cloudproject route, embedded outline/search, tabs/editors, guarded page machines, preview/activity/history/hosted statesessionSole Doc Cloud cache; safe props are project/surface/editor-layout/collapse seeds. Provider is frameset-scoped and cannot pop out.
core.emptynonesessionPlaceholder; no state.
core.diff-viewnone meaningfulsessionDiff is tied to files open at the time.
core.related-notesnonesessionDerived from active draft.
core.kanban-boardlive filter visibility, saved views/selection, header handlerssessionkanban-instance-cache.ts bridges board and header by frameId; handlers unregister on content unmount. Union sweep and hard resource boundaries clear entries; Empty/provider swap and popout eagerly dispose because frameId survives replacement. Board path remains in providerProps; no across-quit UI state.
core.mindmap-boardscroll, filter statesessionSame as core.kanban-board.
core.todo-boardscroll, filter statesessionSame as core.kanban-board.

When adding a new provider that needs per-leaf state (session): add a frameId-keyed module-scoped cache in tauri-app/renderer/services/ (mirror inbox-instance-cache.ts), add a sweepExcept call for it to the write-page tree-change sweep effect, and add a row to this table. Its state is session-scoped by default.

Current code centralizes that sweep in use-stale-frameset-sweep.ts: its keep set is the union of live and inactive-temp frame ids. Add a new cache there and to hard resource-boundary teardown, not to an ad-hoc provider effect. Doc Cloud uses sweepDocCloudFramesExcept; workspace/origin/auth boundaries additionally clear the entire cache.

Retired Directory View state: there is no compatibility provider or migration. Old pre-release pins remain opaque and can show provider-not-found until removed or settings are reset. Do not translate their props into EFE; local file content is outside frameset state and is unaffected.

When promoting a provider to across-quit: the cache must implement all four methods — snapshot(): Record<string, JsonValue>, hydrateFromPersisted(blob): void, subscribe(listener): () => void, notifyChange(): void — and a new debounced persist coordinator (mirror use-inbox-leaf-state-persistence.ts) must merge the snapshot into a new framesetLeafState["<namespace>"] slot in the same settings.save call as frameset. Requires explicit product sign-off.


Gotchas

Provider swap preserves frameId

When a provider is swapped in a frame (e.g., core.emptycore.inbox), frameId is preserved. The cache entry for the old provider is release()-d; the new provider gets a fresh acquire(). Consumers that track frameId (e.g., the active-frame border, scroll-sync) are unaffected.

singletonScope routing is distinct from cache ownership

core.doc-cloud uses singletonScope: "frameset": an open command finds and focuses the existing leaf before creating one. Scope routing decides which leaf may claim an instance; the frameId cache decides where its state lives. They are complementary, not substitutes. The "app" route remains for future truly global providers even though no current provider uses it.

Prop-sync useEffect value-equality gate still required

Any useEffect that mirrors a deserialized provider prop into local state MUST gate on deep-value comparison via a useRef snapshot, per the 2026-05-12 lesson in l-lessons-frameset-persistence. registry.renderLeaf calls provider.deserialize(leaf.props) on every render — the prop is a fresh object reference each time. Reference-equality comparison causes React error #185 ("Maximum update depth exceeded") at startup under real render pressure. This lesson is unchanged by the v2 model.

Per-leaf selectedDraft isolation is necessary but not sufficient — auto-save and focus handoff must also be gated (Epic #2846, #2847, #2848)

Per-leaf selectedDraft in InboxCacheEntry (Epic #1961) prevents the old cross-leaf broadcast (#1955), but it is not enough on its own to prevent a newly-created inbox leaf from corrupting another draft's file during its hydration window.

Three additional guards are required:

  1. Draft-tag-gated auto-save (#2847). useDraftAutoSave compares editorContentDraftRef.current against activeDraftRef.current before writing. If a freshly-focused pane's setContentForPane tags the buffered editor content for a different draft than activeDraftRef currently points at — which happens during the hydration window of a new or swapped inbox leaf whose editor surface still echoes the prior pane's body — the auto-save callback returns early. This makes the hydration window write-free instead of corrupting the active slot's file.

  2. Pre-hydration gate on handleFocusChange (#2847). useInboxDraftStore accepts an isPaneHydrated predicate. When a newly-focused inbox leaf has not yet resolved its real active draft (hydrated: false), the focus-change draft handoff is skipped. Routing a draft change off the pre-hydration sentinel (1) would desync the page-level active slot from the leaf's eventual draft; the leaf's own hydration fires notifyChange, which performs the routing once the real draft is known.

  3. Active-draft seeding at swap time (#2848). applyInitialOptions in makeInboxProvider accepts a getActiveDraft callback. Before the new leaf mounts, applyInitialOptions seeds the fresh cache entry's selectedDraft from the current active draft (guarded: must be a positive number) and sets hydrated: true. This eliminates the sentinel-1 flash and, because the entry is already warm, lets isPaneHydrated allow the focus handoff through immediately.

Together: per-leaf isolation (Epic #1961) + content routing (Epic #2002) + draft-tag-gated single-writer + pre-hydration focus gate + active-draft seeding at swap time (Epic #2848) are what actually close the cross-draft overwrite class of bugs (Epic #2846).

Copy frame — per-frameId cache COPY, not a shared ref (#3004, #3005)

handleCopyFrame in empty-provider.tsx clones a sibling frame into the current empty slot. Key contract points to internalize:

  • Generic providers copy only providerId + structuredClone(props) via replaceProvider. The destination keeps its own distinct frameId and mints its own independent instance-cache entry. The #1955 shared-ref bug cannot be reintroduced by copy-frame.

  • core.inbox additionally seeds the dest entry via inboxInstanceCache.copyState. copyState propagates selectedDraft, viewMode, layoutId, timelineState, gridState; it does NOT propagate editingDraftNumbers (session-ephemeral — always boots empty). It calls notifyChange() so the across-quit snapshot persists the copied state.

  • hydrated propagation is critical. copyState copies source.state.hydrated to the dest rather than forcing true. When the source is hydrated (normal case), the dest starts warm so applyInitialOptions's cold-start re-seed does NOT overwrite the copied selectedDraft on mount. When the source is itself unhydrated (rare boot race), leaving the dest unhydrated lets the mount path resolve the real active draft instead of locking to the sentinel 1.

  • copyState must be called before replaceProvider so the dest entry is already warm when the new inbox leaf mounts and calls acquire(frameId).

  • The scroll-position carryover (captureInboxSourceScrollinbox-pending-scroll.set) must also happen before replaceProvider / copyState so the source scroller is still in the DOM. The inbox-pending-scroll mailbox is transient and one-shot (take() reads and removes) — it is never persisted across quit and cannot accumulate stale state.

  • Draft-bar position is NOT copied — out of scope for v1.

Single ownership — frameset tree

AppSettings.frameset (the singleton tree) is owned by the singleton frameset hook (use-saved-frameset.ts). External writers that need to trigger a tree rebuild must call applyPin or an equivalent orchestrator — not write the tree directly. The per-leaf instance caches are session-scoped module state; they have no AppSettings field, so there is nothing persisted for a writer to contend over.

Instance-cache leak — sweep on tree change

A frameId-keyed instance cache leaks if nothing reaps stale entries: cloneTreeWithFreshIds mints new frameIds on every pin click and a closed leaf's frameId drops out of the tree. release is passive — it does NOT remove the entry. The reaper is use-stale-frameset-sweep.ts, which sweeps every cache against the union of live and inactive-temp frame ids. When you add a new cache, add it there and to hard resource-boundary teardown — otherwise it grows unbounded for the renderer's lifetime. This was a real shipped leak (post-epic review F1): inbox and search caches were added without sweep wiring.


  • l-lessons-frameset-persistence — covers the pre-v2 persistence model, prop-sync useEffect gate (P1), external settings write + hook-resync (P2), React #185 diagnosis path (P3), and upstream ref-internalize pattern (P4). Sub 7 (#1969) first marked the obsolete v1 sections with ⚠️ banners; a 2026-06-06 prune then compressed them into one compact "v1 historical context" section (the two 2026-05-15 syncFromSettings postmortems were dropped — their lesson lives in P2). P1–P4 remain valid. Read that skill for the P1–P4 patterns and for historical context on the old framesets[] / useSavedFramesets design.

  • l-lessons-frame-component-architecture — covers the ViewProvider contract (id, title, icon, serialize/deserialize, defaultProps, singletonScope, canPopOut, consumes, layouts), the page invariant (every route is a thin <Frameset> host), toolbar slot shape, and the active-frame border rule. The provider interface is unchanged by Frameset Arch v2. Read that skill before adding a new provider or modifying the toolbar.


Epic retro — Frameset Arch v2 (Epic #1961, closed Sub 7 #1969)

What we set out to do

The v1 model had two user-visible bugs rooted in structural problems, not surface code errors:

  • #1955 — two inbox frames shared a singleton activeDraftRef; selecting a draft in one leaked to the other.

  • Pin aliasing no-op — pins stored framesetId references; clicking a pin whose backing frameset was already current triggered an id-equality bail and did nothing.

The plan was:

  1. Replace framesets[] + currentFramesetId with a single AppSettings.frameset (singleton tree).

  2. Make pins structural templates with no framesetId reference. Click always rebuilds.

  3. Replace singleton refs with a generic ProviderInstanceCache — a per-provider-id MRU pool with preassignMruSlots to guarantee deterministic slot assignment before any leaf mounted.

Seven sub-issues across the epic: schema migration, cache primitive, inbox/search/terminal/external-file integration, frameset hook refactor, and a final verification sub.

What went wrong

The generic cache was never wired — and was later deleted. packages/provider-instance-cache/ shipped fully implemented (preassignMruSlots, acquire, release, destroy, persist, hydrate, maxSize=10), but getCacheForProvider in app.tsx was left returning undefined, making every preassignMruSlots call inside applyPin a no-op. The user-visible bugs were instead fixed by module-scoped per-frameId caches in tauri-app/renderer/services/, which use an incompatible design (keyed by frameId, no MRU pool). A post-epic 3-reviewer deep review ruled the generic primitive abandoned — "wire it later" would have meant rewriting the live caches — and deleted it, along with the unwritten AppSettings.providerInstanceCache schema field and the dead getCacheForProvider type threaded through app.tsx / toolbar.tsx / use-activate-pin.ts / use-saved-frameset.ts. The shipped module-scoped pattern is now the design, documented as such.

The module-scoped caches shipped a real leak. inbox-instance-cache.ts and search-instance-cache.ts were modelled on terminal-session-cache.ts but the matching sweepExcept wiring in write-page.tsx was never added — only the terminal/EFE caches were swept. Every pin click mints fresh frameIds, so inbox/search entries accumulated unbounded. Caught by the same post-epic review (F1) and fixed by adding the two missing sweepExcept calls to the tree-change effect. Lesson: when you copy a cache module, copy its wiring, not just its shape — and a JSDoc claiming "called by X" is not proof X calls it.

The architecture doc claimed across-quit persistence that never shipped. policy.ts declared core.inbox / core.external-file-editor as across-quit and the doc's policy table repeated it as a user expectation — but the module-scoped caches have no persist/hydrate layer. The doc was corrected to describe the shipped session-scoped reality.

use-saved-framesets.tsuse-saved-frameset.ts. The v1 hook file was plural. The v2 hook was renamed singular. The architecture doc and CLAUDE.md initially still referenced the plural name.

What worked

  • Structural bugs dissolved structurally. Both bugs (shared activeDraftRef, pin-equality bail) required no patches, no special cases once the model changed. The structural fix was correct.

  • Module-scoped caches were pragmatic and sufficient. inbox-instance-cache.ts keyed by frameId is simpler to reason about than a generic MRU pool and directly fixed #1955. Test R3 in inbox-split-independent.spec.ts ("switching draft on left pane does NOT change right pane's draft") covers this closure precisely.

  • P1–P4 patterns from l-lessons-frameset-persistence stayed valid. The prop-sync value-equality gate (P1), external-write resync contract (P2), Playwright-over-jsdom for React #185 (P3), and upstream ref-internalize before removing per-consumer workarounds (P4) — all four are unchanged by v2.

  • AppSettings.frameset (singular) is clean. No framesets[] array, no currentFramesetId, no draftFrameset. The type system enforced the invariant from the start.

Watch for next time

  • Don't build infrastructure ahead of its wiring. The generic ProviderInstanceCache shipped fully built and tested but never wired — and was deleted in the next review pass because the live caches use an incompatible design. Either wire a primitive up-front (with a test proving the integration point is non-trivial) or don't build it. A half-built primitive plus a dead schema field plus a dead type threaded through three layers misleads every reader until someone deletes it.

  • When you copy a cache module, copy its wiring. The inbox/search caches copied terminal-session-cache.ts's shape but not its sweepExcept call site — shipping an unbounded-growth leak. A new frameId-keyed cache is not done until it is in the write-page.tsx sweep effect and the destroyAll paths.

  • Don't document behaviour that doesn't ship. policy.ts's across-quit label and the doc's policy table promised cross-quit restore that the module-scoped caches never implemented. Docs must describe the shipped mechanism, not the aspiration.

  • File renames need a grep pass on all docs and CLAUDE.md. use-saved-framesets.tsuse-saved-frameset.ts was a one-file rename that left stale references.