You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The persistence stack under src/core/StateProvider/ has a clean base (writeQueue → createWriteThroughAtom) but splays at the top into shallow, divergent surface. Investigate how to restructure it into deep modules — small interfaces, substantial hidden implementation — and produce a recommendation + sliced plan.
Three coupled pain points motivate this:
Two divergent factories for one idea.atomWithLocalForage(key, default, reconcile?) and createSessionScopedAtom({key, default, codec}) both seed-before-return, write through the queue, and wrap createWriteThroughAtom. They differ only in storage policy (where the value lives, the cross-tab strategy, serialization). Callers must know which factory to pick, and the two have different parameter shapes for the same job.
storageAtoms.ts is a god-list with an ordering footgun. A positional Promise.all whose array order must match the destructure order (the exact "circular-dep regression" its own DEV NOTE warns about). Adding a key means editing three places, and per-tab vs. shared-vs-reconciled scope is invisible at the call site.
Per-concept knowledge is scattered. A single concept (e.g. graph-view-layout) has its key+default+codec in one file, wiring in storageAtoms.ts, migration in migrateUserLayout.ts, and hooks in another — no single definition of "the persisted entry."
Underlying all three: cross-tab semantics are three unnamed mechanisms (merge / isolate / overwrite), chosen implicitly by factory + optional arg. A reader can't answer "what happens when two tabs edit this?" without tracing code.
Dependency note: everything here is in-process or local-substitutable (fake-indexeddb, in-memory sessionStorage — both already injectable), so each option is deepenable by merging and testing through the interface; no network ports needed. The one genuine seam is the storage backend (localForage / sessionStorage / in-memory), which already has 2+ adapters.
Options to evaluate
Option 1 — One persistentAtom, storage policy as a port
Collapse the two factories into one interface; make the policy a swappable strategy (a genuine port — three real adapters).
typeStoragePolicy<T>={seed(key: string,fallback: T): Promise<T>;// how to read the initial valuepersist(key: string,value: T): void;// how a change is written};functionpersistentAtom<T>(key: string,defaultValue: T,policy: StoragePolicy<T>)// named adapters — the cross-tab semantics, finally named:sharedValue<T>()// blind overwrite (scalars)sharedMap<K,V>()// reconcile-by-key mergeperTab<T>(codec: SessionCodec<T>)// sessionStorage + localForage breadcrumb
atomWithLocalForage and createSessionScopedAtombecome adapters behind the port and stop being public surface. Lowest risk, high depth gain. Does not fix the positional tuple in storageAtoms.ts.
Option 2 — Declarative storage registry
Replace the positional Promise.all with a keyed descriptor map; generate the atoms from it.
defineStorage owns migration ordering, parallel preload, and the key registry behind one call. One entry = one concept, all its facts (key, default, scope, codec, migration) together; scope is visible at a glance. Builds on Option 1's port. Trades named imports for named-key access (churny rename across consumers).
Deepen past the atom into the operations callers actually perform. userPreferences.ts hand-rolls new Map(prev).set(...) / delete twice; schema, configuration, and graph-sessions repeat the same keyed-collection dance.
Highest depth for keyed collections — kills the duplicated upsert/remove/get-with-default logic across ~4 consumers. Narrower (helps Maps a lot, scalars/per-tab singletons less); best as a complement to Option 1, not a replacement.
Option 4 — "Persisted entry" as one concept (registry + operations)
The ambitious unification of 2 + 3: declare a concept once and get its atom and its typed hooks.
storageAtoms.ts becomes a registry of entries; graphViewLayout.ts / userPreferences.ts shrink to feature logic on top. Deepest single concept in the codebase — "a persisted thing" spanning storage → reconciliation → React. Largest blast radius; risks an over-general framework if the per-concept hooks vary more than they appear to. North star, not the next PR.
Recommendation (starting point — validate during the spike)
Preferred sequence: 1 → 3, hold 2 and 4.
Option 1 first — high ROI, low risk. Almost pure win: it names the cross-tab semantics (the currently-invisible, dangerous thing), turns the two factories into adapters behind one port, and passes the deletion test cleanly — three callers would otherwise re-derive the seed logic. The createSessionScopedAtom just shipped in PR Add collapsible Schema View sidebar with shared sidebar shell #1875 is really Option 1's perTab adapter waiting to be named as such, so this is partly a consolidation of existing work.
Option 3 second — concrete ROI. Removes real, current duplication (the Map upsert/remove dance in userPreferences, schema, configuration, graph-sessions). Leverage is measurable, not speculative. Best done after Option 1 so it builds on the policy port.
Option 2 — weakest ROI relative to churn. The positional tuple is ugly but it's one localized footgun, and named-key access trades one import style for another across every consumer. Do it only if the tuple actually bites again.
Option 4 — north star, not the next PR. Only earns its keep after 1 and 3 prove the policy port and store shapes are stable. Jumping straight here risks a framework nobody needed (YAGNI). Highest payoff, highest risk.
ROI summary:
Option
Depth gain
Blast radius
Risk
When
1 — persistentAtom + policy port
High
Small (2 factories + call sites)
Low
First
3 — per-concept keyed stores
High (for Maps)
Medium (~4 consumers)
Low–med
Second
2 — declarative registry
Medium
Medium–high (import-style rename)
Medium
Only if tuple bites
4 — unified persisted-entry
Highest
Largest
High (over-generalization)
After 1 + 3 prove stable
Expected Outcome
A validated (or revised) version of the Recommendation above — confirm the sequence and ROI against a deeper look at the call sites.
A design-it-twice pass on the chosen option's primary interface (e.g. the StoragePolicy port) comparing 2–3 radically different shapes on depth, locality, and seam placement.
A sliced, low-risk rollout plan (one slice per option/collection) with blast-radius notes per slice.
Changing the on-disk storage shape (zero migration).
Implementing any option — this spike produces the recommendation and plan only.
Re-deciding the cross-tab reconciliation strategy itself (covered by the per-key-diff-merge ADR); this is about where that logic lives, not what it does.
Goal
The persistence stack under
src/core/StateProvider/has a clean base (writeQueue→createWriteThroughAtom) but splays at the top into shallow, divergent surface. Investigate how to restructure it into deep modules — small interfaces, substantial hidden implementation — and produce a recommendation + sliced plan.Three coupled pain points motivate this:
atomWithLocalForage(key, default, reconcile?)andcreateSessionScopedAtom({key, default, codec})both seed-before-return, write through the queue, and wrapcreateWriteThroughAtom. They differ only in storage policy (where the value lives, the cross-tab strategy, serialization). Callers must know which factory to pick, and the two have different parameter shapes for the same job.storageAtoms.tsis a god-list with an ordering footgun. A positionalPromise.allwhose array order must match the destructure order (the exact "circular-dep regression" its own DEV NOTE warns about). Adding a key means editing three places, and per-tab vs. shared-vs-reconciled scope is invisible at the call site.storageAtoms.ts, migration inmigrateUserLayout.ts, and hooks in another — no single definition of "the persisted entry."Underlying all three: cross-tab semantics are three unnamed mechanisms (merge / isolate / overwrite), chosen implicitly by factory + optional arg. A reader can't answer "what happens when two tabs edit this?" without tracing code.
Dependency note: everything here is in-process or local-substitutable (fake-indexeddb, in-memory sessionStorage — both already injectable), so each option is deepenable by merging and testing through the interface; no network ports needed. The one genuine seam is the storage backend (localForage / sessionStorage / in-memory), which already has 2+ adapters.
Options to evaluate
Option 1 — One
persistentAtom, storage policy as a portCollapse the two factories into one interface; make the policy a swappable strategy (a genuine port — three real adapters).
atomWithLocalForageandcreateSessionScopedAtombecome adapters behind the port and stop being public surface. Lowest risk, high depth gain. Does not fix the positional tuple instorageAtoms.ts.Option 2 — Declarative storage registry
Replace the positional
Promise.allwith a keyed descriptor map; generate the atoms from it.defineStorageowns migration ordering, parallel preload, and the key registry behind one call. One entry = one concept, all its facts (key, default, scope, codec, migration) together; scope is visible at a glance. Builds on Option 1's port. Trades named imports for named-key access (churny rename across consumers).Option 3 — Per-concept stores (
keyedStore/valueStore)Deepen past the atom into the operations callers actually perform.
userPreferences.tshand-rollsnew Map(prev).set(...)/deletetwice;schema,configuration, andgraph-sessionsrepeat the same keyed-collection dance.Highest depth for keyed collections — kills the duplicated upsert/remove/get-with-default logic across ~4 consumers. Narrower (helps Maps a lot, scalars/per-tab singletons less); best as a complement to Option 1, not a replacement.
Option 4 — "Persisted entry" as one concept (registry + operations)
The ambitious unification of 2 + 3: declare a concept once and get its atom and its typed hooks.
storageAtoms.tsbecomes a registry of entries;graphViewLayout.ts/userPreferences.tsshrink to feature logic on top. Deepest single concept in the codebase — "a persisted thing" spanning storage → reconciliation → React. Largest blast radius; risks an over-general framework if the per-concept hooks vary more than they appear to. North star, not the next PR.Recommendation (starting point — validate during the spike)
Preferred sequence: 1 → 3, hold 2 and 4.
createSessionScopedAtomjust shipped in PR Add collapsible Schema View sidebar with shared sidebar shell #1875 is really Option 1'sperTabadapter waiting to be named as such, so this is partly a consolidation of existing work.userPreferences,schema,configuration,graph-sessions). Leverage is measurable, not speculative. Best done after Option 1 so it builds on the policy port.ROI summary:
persistentAtom+ policy portExpected Outcome
StoragePolicyport) comparing 2–3 radically different shapes on depth, locality, and seam placement.Non-goals
per-key-diff-mergeADR); this is about where that logic lives, not what it does.Related Issues
Important
Internal only — this issue is maintained by the core team and is not accepting external contributions.