How should a stateful component library layer its own writable state over a user's createOptimisticStore? #3085
|
I maintain Solid Flow (a node-graph/canvas library on 2.0.0-rc.3). Like most stateful component libraries — tables, editors, canvases — we accept a user-owned store as a prop and maintain library-owned interactive state on top of it: selection flags, in-gesture drag positions, measurements. Users own membership and data; the library writes the ephemera. With plain stores this composes cleanly. The pattern outlined in the "Write Sync, Run Async" blog is that a user upgrades
const [rows, setRows] = createOptimisticStore(async () => api.list(), []);
// after first settle, outside any action:
setRows((d) => { d[0].selected = true; });
flush();
rows[0].selected; // undefined — reverted immediately
Our best current design is a per-row mirror projection (library-owned rows, user fields derived fine-grained per row, library fields layered on top), entered through tagged factory functions so we can detect the optimistic case at all. Before building it:
Happy to share the full compat test suite if useful. |
Replies: 3 comments
|
Would love to get your insight @ryansolid if you have any time to spare 🙏🏾 |
|
Good questions — and validating them shook out a real engine bug (#3089, fixed on The composition that survives the optimistic upgrade: don't mirror the collection at all. Render membership from the user's store directly, and keep your ephemera in a library-owned store keyed by row id, joined at read time: // library-owned, keyed sidecar — no membership mirror, no syncing
const [lib, setLib] = createStore({} as Record<Id, { selected?: boolean; dragX?: number }>);
// render path: the user's store drives membership AND fields,
// ephemera joined per row by key
<For each={props.nodes}>
{(node) => <Node data={node} selected={lib[node.id]?.selected ?? false} />}
</For>
// library writes are plain writes on your own store
setLib((d) => { (d[node.id] ??= {}).selected = true; });This composes identically over plain and optimistic sources, which dissolves each of your three failure modes:
Critically, this keeps the optimistic UX intact through your library: an optimistic membership edit (user's action pushes a node) renders immediately — the new node appears mid-action, your ephemera on existing rows stay put, and you can even write ephemera for the optimistic row while its action is in flight. At settle the overlay resolves and your sidecar is untouched. We verified this exact sequence in a test against A caution on the per-row mirror projection you're planning: a derived store's output is settle-scoped by design — a projection recomputing from uncommitted inputs is speculative, so committed-visibility readers hold the pre-transaction view until the transaction settles. (The engine can revert an overlay; it can't un-bake an overlay that a recompute already folded into someone else's committed state — so the quarantine is the price of a derive being a store rather than a read.) Concretely: fields you derive into library-owned rows will not show the user's optimistic edits mid-action, and derived membership won't show an optimistically-added node until the server responds. For a canvas where optimistic add/drag is the point, the mirror hides exactly what the user upgraded to see. A derived writable store is the right tool only when you need a transformed collection you own (a sorted/filtered layout array) — and if you do build one, hold the user's row proxies by reference inside your rows rather than copying fields (reads through the proxy pierce to the overlay; copies don't), and accept that membership changes surface at settle. On the silent revert (your question 2): the instant revert of an out-of-action optimistic write is settled semantics — optimistic writes are transaction-scoped by definition, and outside an action there is no transaction to scope to. The write vanishing with no dev-mode signal is fair feedback, though; that's how this cost you time. On detection (your question 3): no predicate planned. With the composition above it's load-bearing that you don't branch on store kind — the library that needs to know is usually the library that's about to write into a store it doesn't own. Tagged factory entry points also shouldn't be necessary for the same reason. |
|
Report-back: we implemented the composition exactly as you described and it shipped this week in Two findings from the implementation that may be useful data points:
Thanks again — marking this as the answer. |
Good questions — and validating them shook out a real engine bug (#3089, fixed on
next) that was making mid-action reads through derived stores look torn, so thanks for pushing on this.The composition that survives the optimistic upgrade: don't mirror the collection at all. Render membership from the user's store directly, and keep your ephemera in a library-owned store keyed by row id, joined at read time: