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/
Core rule
One singleton frameset per app window. No
framesets[]array. The live tree is persisted underAppSettings.frameset(singular).Pins are structural templates. No
framesetIdlinkage. Every pin click always rebuilds the singleton tree from the template viaapplyPin(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 leafframeId.
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- (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/.
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/endReconcileown a private per-entry refcount. Overlapping transitions keepreconcilePendingtrue until all paired tokens finish; callers and subscribers may read the derived flag but must never mutate it.beginReconcileandendReconciledeliberately do not broadcast. The page-level routing subscriber usesreconcilePendingas a read-side gate and retains its last stable numbered target until the provider's trailingnotifyChange().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 sameframeId.reconcilePendingand 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 id | Per-leaf state | Scope | Notes |
|---|---|---|---|
core.inbox | directory (re-seeded from provider props), selected draft/named file, view mode, layout id, timeline/grid/search UI | across-quit | Selection 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-editor | open file set, active tab, embedded-tree root/collapse/expansion state | session | Re-opened from a pin's local-absolute providerProps; tree clicks call the containing session. Identity includes sorted paths + tree root, not collapse. |
core.doc-cloud | project route, embedded outline/search, tabs/editors, guarded page machines, preview/activity/history/hosted state | session | Sole Doc Cloud cache; safe props are project/surface/editor-layout/collapse seeds. Provider is frameset-scoped and cannot pop out. |
core.empty | none | session | Placeholder; no state. |
core.diff-view | none meaningful | session | Diff is tied to files open at the time. |
core.related-notes | none | session | Derived from active draft. |
core.kanban-board | live filter visibility, saved views/selection, header handlers | session | kanban-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-board | scroll, filter state | session | Same as core.kanban-board. |
core.todo-board | scroll, filter state | session | Same as core.kanban-board. |
When adding a new provider that needs per-leaf state (session): add a frameId-keyed module-scoped cache in tauri- (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.empty → core.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:
Draft-tag-gated auto-save (#2847).
useDraftAutoSavecompareseditorContentDraftRef.currentagainstactiveDraftRef.currentbefore writing. If a freshly-focused pane'ssetContentForPanetags the buffered editor content for a different draft thanactiveDraftRefcurrently 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.Pre-hydration gate on
handleFocusChange(#2847).useInboxDraftStoreaccepts anisPaneHydratedpredicate. 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 firesnotifyChange, which performs the routing once the real draft is known.Active-draft seeding at swap time (#2848).
applyInitialOptionsinmakeInboxProvideraccepts agetActiveDraftcallback. Before the new leaf mounts,applyInitialOptionsseeds the fresh cache entry'sselectedDraftfrom the current active draft (guarded: must be a positive number) and setshydrated: true. This eliminates the sentinel-1 flash and, because the entry is already warm, letsisPaneHydratedallow 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)viareplaceProvider. The destination keeps its own distinctframeIdand mints its own independent instance-cache entry. The #1955 shared-ref bug cannot be reintroduced by copy-frame.core.inboxadditionally seeds the dest entry viainboxInstanceCache.copyState.copyStatepropagatesselectedDraft,viewMode,layoutId,timelineState,gridState; it does NOT propagateeditingDraftNumbers(session-ephemeral — always boots empty). It callsnotifyChange()so the across-quit snapshot persists the copied state.hydratedpropagation is critical.copyStatecopiessource.state.hydratedto the dest rather than forcingtrue. When the source is hydrated (normal case), the dest starts warm soapplyInitialOptions's cold-start re-seed does NOT overwrite the copiedselectedDrafton 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 sentinel1.copyStatemust be called beforereplaceProviderso the dest entry is already warm when the new inbox leaf mounts and callsacquire(frameId).The scroll-position carryover (
captureInboxSourceScroll→inbox-pending-scroll.set) must also happen beforereplaceProvider/copyStateso the source scroller is still in the DOM. Theinbox-pending-scrollmailbox 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.
Cross-links
l-lessons-frameset-persistence— covers the pre-v2 persistence model, prop-syncuseEffectgate (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 two2026-05-15syncFromSettingspostmortems 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 oldframesets[]/useSavedFramesetsdesign.l-lessons-frame-component-architecture— covers theViewProvidercontract (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
framesetIdreferences; clicking a pin whose backing frameset was already current triggered an id-equality bail and did nothing.
The plan was:
Replace
framesets[]+currentFramesetIdwith a singleAppSettings.frameset(singleton tree).Make pins structural templates with no
framesetIdreference. Click always rebuilds.Replace singleton refs with a generic
ProviderInstanceCache— a per-provider-id MRU pool withpreassignMruSlotsto 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-, 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.ts → use-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.tskeyed byframeIdis simpler to reason about than a generic MRU pool and directly fixed #1955. Test R3 ininbox-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-persistencestayed 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. Noframesets[]array, nocurrentFramesetId, nodraftFrameset. The type system enforced the invariant from the start.
Watch for next time
Don't build infrastructure ahead of its wiring. The generic
ProviderInstanceCacheshipped 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 itssweepExceptcall site — shipping an unbounded-growth leak. A newframeId-keyed cache is not done until it is in thewrite-page.tsxsweep effect and thedestroyAllpaths.Don't document behaviour that doesn't ship.
policy.ts'sacross-quitlabel 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.ts→use-saved-frameset.tswas a one-file rename that left stale references.