Repository navigation
docs(fetchium): concise docs and 1.0.0 changelog for #67–#74 - #77
Open
abedell-phantom wants to merge 144 commits into
Open
abedell-phantom wants to merge 144 commits into
abedell-phantom wants to merge 144 commits into
Conversation
…hree times `applyMutationEvent` calls `applyEntityRefs(ctx, parsed, /* persist */ true)`, which walks to the event's entity and writes it through `setChildRefs`. It then called `entityMap.save(entity)` on the same entity again, unconditionally, on the next line. `LiveArrayInstance.add` added a third write, calling `child.save()` on a child the same apply pass had already written. Measured against main: an update or create event wrote the entity twice, and an event inserting into a live array wrote the child three times. Tests now assert the counts. Neither removal loses a write. The apply already persisted the entity, which is also what makes the remaining write enough on its own — there is a test that a fresh client over the same store still reads an entity written by an event. The duplication is invisible on `main`, where the apply writes unconditionally, and actively harmful alongside the change-detection work: that skips the write for an entity nothing changed, and this save put it back. On a subscription delivering ~85 entity events per second where 99% carry data identical to what is already stored, that came to ~83 redundant writes per second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Adam Azbel-Bedell <abedell@phantom.app>
…hing This case could not be tested from either PR alone: with the change-detection merge but not this removal, ten identical events still produced ten writes, and with this removal but not the change detection, also ten. Both are now on main, so the combination is finally assertable — ten identical events write nothing, and the next event that actually changes the entity writes once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment claimed the child "was already saved by the apply pass that produced it." That is false whenever the child's data is unchanged: the apply declines the write, so no save happens on this pass. The record is still current, just not necessarily written by this pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…anged entity An entity can enter the graph through one query and later be inserted into a live array by an event carrying identical data. The apply declines the child write, so the insert must not add one: child 0 writes, parent 1. Fails on main, where add() re-saves the child. Also hoist the List fixtures now that two tests use them, and raise the refetch delay in the later-client test so the store read cannot race it — it passed at every delay from 0 up, so the value was never load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The change detection that skips an unchanged write is now in place, so removing these saves takes a streamed event that changes nothing from one write to none. That is the primary effect and the changeset omitted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The later-client test waits a single macrotask tick, so the refetch only has to be slower than that — it passed at every delay from 0 upward, so there was never a race. 10s was arbitrary margin and left a long pending timer behind after the test finished. 1s keeps the network value unable to land while staying proportionate: roughly 20x the worst sleep overshoot seen on a loaded runner here, where a sleep(10) has overtaken a 50ms mock delay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #60 skips the entity write when applied data is unchanged, keyed on an in-memory `_persisted` flag. The store deletes records on its own (LRU eviction past `cache.maxCount`, refcount cascades, stale purges) without telling the entity, so the next unchanged apply skipped the write while the query record was re-saved pointing at a missing value; on the next cold start the query's cache failed to hydrate and was dropped. Any mounted query with more than 50 live keys of one definition, polling unchanged data, hit this continuously. - `QueryStore.onDelete?(listener)` (optional, additive). Both built-in stores report every id they drop; the client clears `_persisted` on the matching entity. Stores without `onDelete` get every persisted apply written, as before 0.6.0. - Snapshot fast path: union members stored under the array/record symbol keys are now classified, so `t.union(t.array(t.entity(A)), t.object(...))` is dynamic and re-read (was static -> stale in prod, throws in dev). - Entity-array narrowing (shared typenames) is refreshed per apply instead of only when the array's identity changes. - `AsyncQueryStore`'s writer now honors a query def's own `cache.maxCount` instead of always applying the default of 50. Tests: store-consistency.test.ts (R1 x2, R3), store-on-delete.test.ts (x4), entity-snapshot.test.ts (R2). All fail on main, pass here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
applyMutationEvent applied an unknown entity with persist: true and then evicted it from memory if no live collection matched. The record stayed in the store with no refcount, so nothing ever collected it — a slow leak for clients that receive broad streams. A new entity is now written only once something routes it: applyEntityRefs runs with persist: false for a create, and persistUnwritten writes the entity (and any not-yet-written descendants) after routing finds a match. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #64 trusts an entity's _persisted flag whenever typeof store.onDelete === 'function'. AsyncQueryStore defined onDelete as a class method, so a reader (isWriter: false, the documented main-thread/worker topology) qualified — but deletions only ever fire listeners in the writer instance (writerDeleteValue). A reader's client therefore still had the PR #60 hole: after the writer evicts and cascades, the reader skipped the unchanged write, re-saved the query with a dangling ref, and the next cold start dropped that query's cache. onDelete is now an optional property assigned only in the writer's constructor, so a reader offers no hook and its client falls back to writing every persisted apply, as before 0.6.0. Restoring the write-skip for readers is a separate follow-up: the writer would need to post a Deleted message back over the channel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`useQuery` deep-snapshots its result through `useReactive` on every dependency change, and the entity snapshot handler walked the proxy to do it. `Object.keys` triggers a `getOwnPropertyDescriptor` trap per key, that trap calls `handler.get` to produce the value it reports, and nested plain data came back as the read-only wrapper proxies the `get` trap returns, which the snapshot then walked through more traps. Every field was read twice per snapshot and each read allocated a descriptor. Each proxy now registers an `EntitySnapshotSource`: consume the notifier and activate the entity relay once, list the enumerable own keys, read a field's raw value. The handler walks nested plain data itself, unwrapping `WRAPPED_VALUE` holders and handing nested entity proxies back to Signalium so they still consume their own notifiers. The value half of the `get` trap moved into `readField` so the trap and the snapshot share one reader. On top of that, `EntityInstance` carries a `version` bumped in `notify()`. A snapshot records the source id, version and key list it came from, so re-snapshotting an entity still at that version re-reads only its dynamic fields: child entities, live collections, and a query's attached getters. `isStaticFieldDef` decides that per shape, recognising only the def shapes `getEntityDef` validates against and treating everything else as dynamic, so an unfamiliar def costs a read rather than serving a stale value. Array items are paired with the snapshot they produced themselves rather than with whatever sat at the same index. Signalium diffs arrays by position, so a front insert handed every entity its neighbour's snapshot and every memoized row re-rendered. The pairing index is built from the consumer's own previous array, so two consumers keep independent trees, and only when a mispairing is detected, so appends don't pay for it. Guarding the result, since none of this is observable from behavior: - Dev builds re-read the fields the fast path skipped and throw if one moved. That catches both a merge that changes `data` without notifying and a field wrongly judged static — the two ways a snapshot could go permanently stale — at the point it happens rather than in a render. It costs dev builds the read the fast path saved. - `__debug_snapshotFullWalks` / `__debug_snapshotFieldReads` let tests assert the fast path is taken. A regression that quietly routes every snapshot through the full walk leaves every behavioral test green. - `src/__benchmarks__/snapshot.bench.ts` and `npm run bench` measure the paths this touches, built with `IS_DEV: false` so the dev guard doesn't skew them. Two smaller behavior notes: - `getOwnPropertyDescriptor` answers from the enumerable key list rather than `prop in shapeFields`. `shapeFields` is an object literal, so `in` was true for inherited names and the trap reported `toString` as an enumerable own property with value `undefined`. It now returns `undefined`. `Object.keys(proxy)` was never affected — it filters `ownKeys`, which never listed inherited names. - The `Entity`-without-a-snapshot-source fallback is gone. Its own properties are the shape's type defs, so walking one produced a plausible-looking object of `t.string`/`t.number` values rather than data. Falling through returns the instance untouched, which is what Signalium does for any class it has no handler for. Costs, measured: the production `QueryClient` chunk grows 80,581 -> 83,781 raw and 21,597 -> 22,647 gzipped (+1,050, +4.9%) for the per-shape key metadata, the static-field analysis and the origin bookkeeping. Dev-only code is fully eliminated — the invariant check and the debug counters appear in no emitted production JS. The snapshot origin holds the source's id rather than the proxy: a snapshot outlives the entity it came from, and a strong reference would have made the entity graph reachable from a detached snapshot tree. Ids are per proxy, which `PROXY_ID` is not — an entity has one proxy per shape and they share a key. Output is otherwise unchanged: same key set, same structural sharing, same reactive dependencies, no public API change. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(fetchium): don't notify or persist when applied data is unchanged `applyEntity` called `notify()` and persisted on every update, whether or not the data differed from what the entity already held. A refetch or poll returning identical data produced one notify per entity, a consumer recompute for each, a full re-snapshot and one store write per entity, to arrive back where it started. `mergeFields` now reports whether it changed anything and `applyEntity` acts on that. Comparison is by value: identical scalars, formatted values built from the same raw input, arrays and plain objects element by element, entities by proxy identity. Anything it cannot compare confidently — a Date, a typed array, a class instance — counts as different, so the failure mode is an unnecessary notify, not a dropped update. Live collections report the same, and `LiveInstance.reset`/ `append` return a boolean for it. A field that compares equal keeps its existing value, so nested objects and arrays hold their identity. Two pre-existing bugs this reaches, both fixed here: - Record fields never applied an update. A record def's `shape` is its value type — a bare Mask for `t.record(t.string)` — and the merge recursed into it as though it were a set of field defs, iterated nothing, then restored the old value. An update, addition or removal inside a record was silently dropped. Nested field records are now recognised by shape, and records fall through to the value comparison. - The persist decision no longer assumes an unchanged entity is already in the store. `persist` is caller-supplied and the cache-hydrate path passes `false`, so hydrate-then-fetch-identical could have skipped an entity's only write. `EntityInstance._persisted` makes that explicit. `sameRefs` compares the ref key set rather than counts, matching what the store persists and what `setChildRefs` acts on; comparing counts forced writes that produced byte-identical bytes. `sameValue` uses `Object.hasOwn` rather than `in`, which reached the prototype and could compare a missing key against `Object.prototype`'s answer. Tests: 11 integration cases in `entity-change-detection.test.ts`, 10 of which fail against main, plus 34 table-driven cases covering `sameValue` and `sameRefs` directly — every branch, and the fail-safe direction for each type they cannot compare. The identity assertions use `toBe`; `toEqual` cannot observe the property being claimed and passes on main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(fetchium): address review feedback Comments: cut the `needsPersist` rationale to the one non-obvious line, the `_persisted` field doc to its first sentence, and made `sameMembers` read declaratively. Dropped the throwaway const around the live reset/append call. Fixed a misplaced JSDoc: the block describing structural field equality sat above `sameKeys`, which only compares key sets. Moved it to `sameValue` and gave `sameKeys` its own one-liner. Tests: `sameKeys` is now exported and covered like `sameValue` and `sameRefs` (own-keys-only, added/removed keys, a key holding undefined). Added two change-detection cases — a live-array member's own field changing, which must notify the member and leave the list quiet, and a mutation event carrying a nested entity, which must notify only the entity that actually moved. Both fail without the change detection in this branch. Hoisted the `Item`/`List`/`GetList` declarations to module scope with a `list()` data helper; the same block had been copy-pasted into three tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(fetchium): assert the nested entity's merged value, not just its notify The nested-entity change-detection case pinned which entity notified but never what the data became, so a notify that fired without the merge landing would have passed. Reads the child instance back and asserts its field after both events — including the unchanged one, where skipping the notify must not skip the merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(fetchium): assert merged values in the mutation-event cases The unchanged-fields case pinned which entity notified but never what the data became, the same gap just closed for the nested-entity case. Reads the instance back and asserts its field after both events. Also corrects the comment on both: for an event that carries the current value there is nothing to merge, so the assertion guards against the no-op path damaging the value rather than against a skipped merge. The changed event is what pins the merge landing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(fetchium): name the mutation-event case for both directions it asserts The name covered only the unchanged half, but the test also applies a real change and asserts the notify and the merged value. That second event is the positive control: without it, the empty-notify assertion above would pass just as happily if the recorder never fired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds opt-in client options, all defaulting to current behavior: - reactivationGraceMs (+ per-query override): a reactivating query whose data is younger than the grace is not refetched. Data a running subscription kept current counts as fresh until deactivation. Invalidation, refetch(), network reconnects and failed fetches still refetch. - reactivationStaggerMs: reactivation refetches queued in the same task are spread evenly over the window; adapters that set coalescesRequests keep theirs together. - activity + pollResumeJitterMs: poll() stops timers while the app is inactive and spreads overdue ticks (including ticks that fire over a second late after frozen timers) across the jitter window. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion delivered The grace counted any running subscription as keeping the data current until the query deactivated. A poll() that never ticked qualified: it restarts its full interval on every activation, so visits shorter than the interval separated by gaps shorter than the grace chained the grace forever and never refetched (poll 60 s, staleTime 0, grace 10 s: twenty 50 s visits served 18-minute-old data from one fetch). The same credit covered time a focus-aware poll spent stopped in the background. Now only a delivery counts: a stream event through the subscription's onEvent, or a topic event the adapter sends with its topic (sendMutationEvent(event, topic)), and only up to the last one. A poll() keeps data current through the fetches it makes, which already move updatedAt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… first fetch on a microtask A query activated against SyncQueryStore (MMKV on mobile) resolved with its cached value a microtask after activation, so the first React render showed a loading state and a second, sync-lane render showed the data. The first fetch then waited on setTimeout(0). Apply the cached value in the activating read when the store returns synchronously, and run the subscription and first fetch on a microtask, still outside the relay's computation. AsyncQueryStore keeps its async path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…edundant-entity-saves
…uring the window A staggered refetch waits up to reactivationStaggerMs and then started unconditionally. A refetch() or poll tick in that window was aborted by it and repeated, and a fetch that had already landed was repeated too. It now starts only if no fetch has started since the query was queued. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lead with what changes for an app using useQuery or fetchQuery, keep migration notes to a line or two, and drop internal detail. No source change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nseQuery
Read-path latency:
- A refetch with no debounce (stale reactivation, Signal param change) now
starts on a microtask instead of setTimeout(0). On React Native a zero
timer goes through the native timing module and can wait a frame. Stale
reactivation goes from 1 macrotask turn to 0, a param change from 2 to 1
(the remaining one is Signalium's flush), a staggered reactivation from 2
to 1. Zero-delay refetches requested in one task still coalesce.
- Invalidation keeps the timer, so a query whose last reader left in the
same task is deactivated by Signalium's flush instead of refetched and
aborted.
New API:
- QueryClient.prefetch(QueryClass, params, { ttl }) starts a query and keeps
it active for ttl (default 10 s). A reader that mounts inside the window
joins it: no second request, data on the first render if it has arrived.
- QueryClient.retain(fn, { ttl }) keeps the queries fn reads or returns
active until release() or ttl. Built on public Signalium 3.0.3 APIs
(watcher + addListener), mirroring the unreleased signalium retain() but
bound to the client's QueryClientContext. destroy() releases leases.
- useSuspenseQuery (fetchium/react) suspends only on a cold miss, never on a
refetch; it decides before subscribing and has the client hold the query
while suspended, so a discarded render leaks no watch. A failed cold
fetch is thrown to the error boundary; a boundary reset retries.
Docs: useQuery's .value never suspended and a rejected .value never threw;
the agent doc's `{ suspended }` option never existed. Fixed, and documented
the new API.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rent value A stream reconnect re-delivers every entity as an update event. Entities, writes and live-array membership were already quiet for identical data, but every update event still ran the live value's onUpdate reducer and notified unconditionally, even when the reducer returned the value it was given. Only notify when the reduced value changed, as LiveValueInstance.reset already does. Adds tests pinning identical re-delivery as a no-op across query results, mutation events, topic events and nested entities (no entity notify, no version bump, no write, no live collection notify, no React re-render), and that a real change still notifies exactly once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A query activated by a read leaves its subscription and first fetch for a microtask, since that work runs adapter and app code that must not run while the relay's computation is the current consumer. Leases paid the same hop: a tap handler that called prefetch() and then navigated had React's sync render microtask (queued by the press) run before the request went out. retain() and prefetch() now collect the queries their first run left a start for (a cold start from initialize(), or a zero-delay runDebounced() refetch on reactivation), and run those starts once the watcher's read has returned, outside any computation. The queued microtask then does nothing. Reader activation is unchanged, as are the stagger queue and debounce timers, the reactivation grace, and sync-store hydration (fresh cached data still issues no request). useSuspenseQuery's internal hold keeps the deferred start, since it leases from inside a render. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A failed attempt whose HTTP status is 4xx (except 408 and 429) now fails at once instead of retrying three times. The status comes from the adapter's error response (ctx.response set during the attempt) or from status, statusCode or response.status on the error. QueryClientConfig.shouldRetry and RetryConfig.shouldRetry override the default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Skipping the notify when a reducer returned the value it was given broke reducers that mutate in place: one that pushes onto its array and returns it changed the value without notifying, so readers kept the old length. A reference check can't tell that from a no-op, so every routed event that runs a reducer notifies again. Entities, writes and live arrays stay quiet on identical re-delivery. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tch already settled When the fetch a useSuspenseQuery hold waited for settled and another fetch started before the reader rendered (a refetch from elsewhere, a reconnect), the hold kept handing out its already-resolved promise. React re-rendered at once on each throw, spinning tens of thousands of renders until the new fetch landed. A hold whose fetch settled now starts over on the fetch in flight, with a fresh promise. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/fetchium/src/QueryResult.ts
Calling Object.keys(def.shape) once makes later iterations over the shape faster on V8. In-memory event benches run 7-30% faster. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An error occurred while trying to automatically change base from
fix/stack-timing-and-reactivity
to
fix/perf-stack-regressions
October 8, 2026 00:08
…d lease behaviour Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
abedell-phantom
changed the base branch from
fix/stack-timing-and-reactivity
to
main
October 8, 2026 00:10
jimmy-phantom
previously approved these changes
Oct 8, 2026
The 1.0.0 release consumed the changesets, so only the docs changes remain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The perf stack added multi-line JSDoc that repeats edge cases the docs pages cover. State the contract, and keep what a custom store author needs: what happens when an optional hook is absent. Comments only. No behavior change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rewrites the docs #67–#74 added, so they say only what a reader needs: the 1.0.0 changelog entry, the source JSDoc on the new config and store hooks, and the React section of the agent plugin doc. No behavior change.
fetchium,fetchium/react,stores/sync,stores/async): descriptions cut to one or two sentences.fetchium/reactnow separates the hooks (plain React components) fromfetchQueryinsidecomponent().CHANGELOG.md1.0.0: replaced the shipped changeset text with the concise version, with the same entries and commit refs.🤖 Generated with Claude Code