Skip to content

docs(fetchium): concise docs and 1.0.0 changelog for #67–#74 - #77

Open
abedell-phantom wants to merge 144 commits into
mainfrom
docs/concise-perf-stack-74
Open

abedell-phantom wants to merge 144 commits into
mainfrom
docs/concise-perf-stack-74

Conversation

@abedell-phantom

@abedell-phantom abedell-phantom commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • API pages (fetchium, fetchium/react, stores/sync, stores/async): descriptions cut to one or two sentences. fetchium/react now separates the hooks (plain React components) from fetchQuery inside component().
  • Guides (error handling, streaming, entities, caching, project setup): trimmed the added paragraphs and fixed the streaming example.
  • CHANGELOG.md 1.0.0: replaced the shipped changeset text with the concise version, with the same entries and commit refs.

🤖 Generated with Claude Code

abedell-phantom and others added 30 commits September 10, 2026 15:50
…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>
…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>
abedell-phantom and others added 7 commits October 7, 2026 18:44
# 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>
Comment thread docs/src/app/api/fetchium-react/page.md
Comment thread docs/src/app/api/fetchium/page.md Outdated
Comment thread .changeset/activation-timing-fixes.md Outdated
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
abedell-phantom deleted the docs/concise-perf-stack-74 branch October 8, 2026 00:09
@abedell-phantom
abedell-phantom restored the docs/concise-perf-stack-74 branch October 8, 2026 00:09
jimmy-phantom
jimmy-phantom previously approved these changes Oct 8, 2026
The 1.0.0 release consumed the changesets, so only the docs changes remain.
@abedell-phantom abedell-phantom changed the title docs(fetchium): concise docs and changesets for #67–#74 docs(fetchium): concise docs for #67–#74 Oct 8, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@abedell-phantom abedell-phantom changed the title docs(fetchium): concise docs for #67–#74 docs(fetchium): concise docs and 1.0.0 changelog for #67–#74 Oct 8, 2026
abedell-phantom and others added 2 commits October 7, 2026 20:20
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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants