Repository navigation
fix(fetchium): activation timing, nested-value reactivity and config fixes on the perf stack - #74
Merged
abedell-phantom merged 37 commits intoOct 8, 2026
Conversation
abedell-phantom
added this pull request to stack #72
October 6, 2026 05:08
abedell-phantom
marked this pull request as ready for review
October 6, 2026 15:27
abedell-phantom
force-pushed
the
fix/stack-timing-and-reactivity
branch
from
October 6, 2026 15:29
1916164 to
4d34bc3
Compare
…to a failed fetch The first fetch and zero-delay refetches now start on a microtask, ahead of Signalium's flush, and that flush is where a query whose last watcher left is deactivated and where a Signal param change reaches it. Three cases that a timer used to absorb now surface as errors: - A query mounted and unmounted in one task sent its first request, the deactivation aborted it and the relay settled with the AbortError, so the next mount showed an error. A first fetch started while that flush was outstanding is now left to finish, as the timer-started one did: the next mount shows its data. One `settled()` per task that starts a first fetch tracks the window; nothing is added per render or apply. - A Signal param change made in the task the last watcher leaves sent a request for the new params that the deactivation then aborted. The param-change refetch now waits for Signalium's outstanding flush (no extra task when the change arrives through that flush) and is cancelled by the deactivation like the timer was. - A stale reactivation in the task the network went offline rejected with "Query is paused due to network status". The deferred start, the zero-delay and debounced refetches and the deferred restart now return early unless the query is active and not paused; deactivate() also cancels a start that hasn't run. A relay that a deactivation's abort settled as rejected is restarted on the next activation, like a relay still pending without a controller, instead of showing the AbortError until a refetch lands. Its cached value is re-settled first, so the refetch doesn't carry the error. A caller awaiting the aborted fetch still settles with the AbortError. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… changes around a fetch A Signal param set in the same task as the query activates reaches update() only in Signalium's next flush, but the first fetch now starts on the microtask before it. It sent the old params, and when the flush applied the change, runDebounced() bailed because the relay was pending: the old params' response was shown as the new params' value and persisted under the new key, so a cold start served it again. A change while the fetch is in flight did the same in every earlier version. - The deferred first start re-reads Signal params (only for queries that have one) and sends the current ones. - A param change while a fetch is in flight, or queued, aborts it and restarts with the new params: the relay takes the new promise at once, and the request starts after Signalium's outstanding flush, outside update(). Reactivating with changed params while a fetch is in flight restarts the same way. - runQuery() remembers the storage key it started with and drops a response for a key that is no longer current (an adapter that ignored the abort) instead of applying and persisting it. - A param change resets the query's timestamp the way invalidation does, so the old params' fresh timestamp can't skip the new params' fetch. The only extra request is the one replacing a fetch for params that are no longer current. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first fetch a query starts on its microtask is left to finish when the Signalium flush outstanding at that moment deactivates the query (a mount and unmount in one task). A lease starts its queries' first fetch synchronously instead, and `release()` with no reader is documented to abort a fetch in flight; a release in the same task as `prefetch()` or `retain()` fell into that window and let the request run. Only the microtask start opens the window now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r own `t.object` and `t.record` fields are merged in place and live collection values change in place, so the wrapper a nested value is handed out through keeps its identity across an update. Reads through that wrapper consumed nothing; only the entity proxy's own read did. A `component()` child given `token.price`, `wallet.balances` or `list.items` as a prop therefore had no dependency of its own, and once its parent stops re-rendering it for identical props (Signalium's identical-props hash skip) it kept the old value: a refetched price, a merged balance, a streamed create. Each wrapper is now its own proxy handler and carries a notifier that its traps consume: - an entity's nested object, record or array gets its own notifier, created on the first read; `mergeFields` notifies it when an in-place merge changed that value (a nested `t.object` merge reports a change, or a same-keys record copies a value); - a live collection's value, and anything inside it, follows the collection's notifier, which a reducer or an event already notifies. Consuming the entity's notifier instead would also have fixed staleness, but every unrelated field change on the entity would then re-render a child that holds only `price`. With a notifier per nested value, readers re-run only when their value changed. Cost: no extra work in a snapshot or on an unchanged apply. A read through a wrapper reads a handler field instead of a module-level handler and, inside a computation, records one dependency (a few ns per read). A wrapper allocates its notifier once, on its first read. A changed nested merge does one WeakMap lookup; the separate proxy cache is gone, the wrapper map replaces it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reads of `t.object`, `t.record` and live array values through their wrapping proxies, held on their own or reached through the entity, inside and outside a reactive computation, plus a cold query that creates a wrapper per row. `npm run bench` runs it with the snapshot benchmarks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The fast-delivery work attached `_notePush` to each query's execution context as a plain property, so `Object.keys(this)` (or a spread) inside a query method listed a key it never had before. It is now defined non-enumerable; TopicQuery still reads it by name. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Custom QueryClientConfig keys have always reached query code as `this.context`. The reactivation and polling options gave five of those names a meaning, which broke apps already using them: - poll() called `activity.subscribe` on whatever the context held under `activity`, so an app value of its own under that name failed every polled query before its first fetch, with nothing logged. poll() now uses the value only when it has `isActive` and `subscribe` functions, and otherwise polls as without one, warning once per client in development. A real activity source, including one with prototype methods, works as before. - `QueryContext` declared `activity` and `pollResumeJitterMs`, so an app that had augmented it with its own `activity` field no longer compiled (TS2717). They are config options, not context fields, and are no longer declared there. - `shouldRetry`, `reactivationGraceMs` and `reactivationStaggerMs` were taken out of the config before it became the context. They reach the context again, as every key did before. A `shouldRetry` that is not a function is ignored rather than called on the first failure. The docs now say these names are reserved. - `reactivationStaggerMs: Infinity` turned into setTimeout(..., Infinity), which Node clamps to 1 ms with a warning and other runtimes may never fire. A non-finite stagger or `pollResumeJitterMs` now counts as 0. `reactivationGraceMs: Infinity` keeps meaning "never refetch on reactivation", since it sets no timer. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… loop useSuspenseQuery dropped a failed cold fetch's hold when no render claimed the error within 50 ms. React starts the retry render soon after the promise settles, but a time-sliced tree with expensive siblings can take longer than that to reach the reader. That render found no hold, took a new one and refetched, and so did every render after it: a failing endpoint got a request every ~100 ms and the error boundary was never reached. - An unclaimed failure now waits 1 s for a render. - If it still expires and the attempt the next mount makes fails too, that error waits as long as an unsettled hold (10 s), so even a slower reader gets its error after one retried request. - A claimed error is still let go on the next task, so an error-boundary reset retries as before. The trade-off: a tree abandoned while suspended and mounted again within a second of its fetch failing now shows that error instead of making a new attempt. The abandoned-remount test waits past the window to keep checking the new attempt. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ixes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…etches - A first fetch kept across a same-task unmount is now kept only when no subscription is running. A topic query's send() waits on the subscription the deactivation tears down, so the kept fetch could never settle and the next mount stayed pending, with refetch() a no-op. It is aborted as before and the next activation restarts it. - A refetch deferred until Signalium's outstanding flush has run (a Signal param change) now waits at most one task. settled() also waits for every flush scheduled after it, so a listener that keeps writing signals could hold the fetch back until the chain stopped. - With reactivationStaggerMs, refetches a pause or an offline switch aborted are re-settled with their cached value and spread across the window again on resume, instead of all restarting at once. - QueryClient.destroy() aborts each query's fetch in flight and cancels deferred ones, so a kept first fetch no longer writes to the store after the client is gone. - A fetchNext page for params that changed while it was in flight is dropped, as runQuery() already drops a stale first page. - An asynchronous cache load for params that changed while it was pending is not applied, and the start it schedules is skipped when the param change already fetched. A corrupt-entry discard deletes the key it loaded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tead of tracking reads Reads through an entity's nested object, record or array consumed a notifier per wrapper, which made every such read, render and reactive() reads included, 14-24% slower inside a computation and allocated a handler and a notifier per wrapper. A merge that changes a nested value in place now drops the wrapper it was handed out through. The next read through the entity, which the entity's notifier already re-runs, hands out a new wrapper, so a child given only the nested value (a component() or React.memo child given token.price) sees a new prop and re-renders. An unchanged nested value keeps its wrapper, so the child is left alone when only another field changes. Entity wrappers go back to the shared handlers and consume nothing. A live collection's value still changes in place under the collection's notifier, so its wrappers keep consuming that notifier (it already exists, nothing is allocated per wrapper). The wrapper map is also read through a module-local binding: under vitest's module transform an imported binding is a property load on every read. With both changes the nested-read bench is back at the numbers it had before nested values were made reactive. The React.memo caveat test in component.test.tsx now expects the child to show the new value: the stale display it documented is fixed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t or GC cancels a query's fetch A correctness review of the timing fixes found awaiters of a query's relay left waiting forever, and a collected query whose fetch kept running: - destroy() cancelled a first start or a restart that had not run yet, so nothing ever settled the relay. A queued restart now runs against its aborted controller and rejects; a relay pending with no fetch at all is rejected with an AbortError. - A restart that found the network offline left the relay pending with no fetch. If the query was then unmounted, the deactivation had nothing to abort, and the relay stayed pending until a later mount while online. The restart's rejection is now kept, and a full deactivation rejects it (and restarts on the next activation), as the abort it replaced would have. Coming back online still restarts it. - With a real GC manager (browser, React Native), a gcTime: 0 query mounted and unmounted in one task was collected while its kept first fetch was in flight. The fetch then applied to an instance nothing owned: its entities stayed resident and its store write ignored destroy(). Collection is now scheduled when that fetch settles, and a collected query has its work cancelled and its relay settled. - TopicQueryAdapter.send() ignored the abort signal, so whoever awaited a topic query mounted and unmounted in one task waited for the next mount. It now rejects when the signal aborts. Nothing here runs on a render, an apply or a successful fetch, apart from one signal compare in runQuery()'s finally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A query whose result is not an entity keeps its data in a root entity whose key came from the params the query instance was created with. A Signal param change switched the storage key but not that root, so every params key's query record pointed at the same root record, which held the newest params' data. On a cold start within staleTime, the old params were served the new params' data with no request. In memory, a second query showing the old params shared the root and was overwritten too. This is in every earlier version; it is not caused by the perf stack. The root's id now follows the current params. The first apply after a change builds the new params' root, so the relay hands out a new result object for them, and the previous root is evicted unless another query instance still shows it. Entity results are unaffected: their root is the entity itself. Cost: one hash per param change and one compare per apply. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…the signal has no reason TopicQueryAdapter.send() rejects with the abort signal's `reason` when the query's deactivation aborts it. React Native installs an AbortController polyfill whose signal has no `reason`, so there the awaiter was rejected with `undefined` and the relay reported a rejection with no error. It now falls back to an AbortError, through the helper retry() already uses for the same case. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p still holds it Since a non-entity result's root key follows the current params, two query instances whose params agree share one root. Two paths then evicted a root another query was still showing: - The first apply after a param change released the previous root with evict(), which removes whatever the map holds at the root's key. When the previous root had already left the map (its query was collected while a reactive function kept the relay, and a new query for the same params has built a root at that key since), that removed the new query's root: the next apply rebuilt it, handing out a new result object for unchanged data, on every refetch. - Collecting a query evicted its root even when another query showed it. A query whose Signal param had moved onto another query's params took that query's root with it, so streamed updates stopped reaching what it showed. The release now leaves a root that is no longer in the map alone, and collection goes through the same release as a param change: the root is evicted only if no other query instance shows it. Entity results are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nmounted before its first data Since send() honours the abort signal, a topic query unmounted before its first data, in a later task than it mounted, rejected its relay with the deactivation's AbortError. Signalium keeps a rejected relay's error until the relay gets a value, so when the query was mounted again it reported `isRejected` with that AbortError until the refetch landed. A screen that checks `isRejected` before `isPending` (a list row scrolled away before its data and back) showed its error state for that time. Before, the relay stayed pending. For a query with a subscription and no value yet, that abort now leaves the relay pending. The next activation's refetch replaces the fetch, so whoever awaits the relay gets that refetch's result; collection and destroy() settle it with an AbortError, as they settle any relay left pending. A restart that found the network offline is held the same way when such a query is unmounted. A query mounted and unmounted in one task still rejects at once: whoever started it is a one-off read that awaits it. Queries without a subscription, and relays with a value, are unchanged. Nothing runs on a render, an apply or a fetch that is not aborted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…reading it back each time A full-payload update event for an entity that is not in memory refreshes the entity's record on disk when the store holds one. On a typename that more than one class declares (a list's class and a detail class), the payload lacks the other classes' fields, so the write is a merge, and SyncQueryStore.mergeEntity read the stored record and parsed it on every event, then serialized the result again. A high-rate stream of such events paid that each time: about 40% fewer events per second than before these records were written at all, against 18% for a single-class typename. The store now keeps, per entity record it merged into, which fields the merge carried and what the record holds besides them, as a JSON fragment with the references inside it. The next merge of the same fields writes the payload's JSON with that fragment appended, and takes its references from the markers in the result, without reading the record. Only the first merge per entity reads. Every other write and every deletion of a record through a store over the same kv drops its entry, so a merge never restores a value another write replaced; a merge of different fields reads again. At most 1024 entries are kept, least recently merged first out. The added cost elsewhere is one size check per entity write while nothing was merged, and one map delete per write once something was. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nted mid-fetch before its first data A query with no value that was unmounted mid-fetch, in a later task than it mounted, rejected its relay with the deactivation's AbortError. Signalium keeps a rejected relay's error until the relay gets a value, so when the query was mounted again it reported isRejected with that AbortError until the refetch landed. A chart that switches timeframe away mid-fetch and back showed its error state for that time. Topic queries were already held pending in this case; every query with no value now is. The next activation's refetch settles whoever awaits the relay; collection and destroy() settle it with an AbortError. A query mounted and unmounted in one task, a pause because the network went offline, and a restart parked offline then unmounted keep rejecting their awaiters at once, unless the query has a subscription. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
abedell-phantom
force-pushed
the
fix/stack-timing-and-reactivity
branch
from
October 6, 2026 17:01
4d34bc3 to
8eba14e
Compare
…eactivity # Conflicts: # packages/fetchium/src/QueryResult.ts
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An adapter that ignores the abort could still resolve after destroy() or GC collection, and its data was applied and saved. A counter bumped by abortForDestroy() now makes runQuery() and runFetchNext() drop it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
destroy() now sets a client-level flag first. Store writes (entity persist, saveQueryData, activateQuery, deleteQuery) are dropped once it is set, and runQuery/runFetchNext drop a late response from any instance, including one collected and then reactivated outside queryInstances. abortForDestroy() also stops the subscription, so poll() timers end. Starts, refetches, restarts, network updates and async cache-load callbacks do nothing after destroy, a pending relay rejects with an AbortError, and getAdapter() no longer builds a default adapter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eactivity # Conflicts: # packages/fetchium/src/QueryClient.ts # packages/fetchium/src/QueryResult.ts # packages/fetchium/src/__tests__/retain-prefetch.test.ts # packages/fetchium/src/stores/sync.ts
…late Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eactivity # Conflicts: # packages/fetchium/src/QueryResult.ts
jimmy-phantom
previously approved these changes
Oct 7, 2026
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>
jimmy-phantom
approved these changes
Oct 7, 2026
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.
Base:
fix/perf-stack-regressions(#73, the September fix,3fa63ba, on top of #71). Branch:fix/stack-timing-and-reactivity, head4d34bc3. Twenty commits.Why
A second review of the two perf stacks (Fetchium #67–#71 and Signalium #251–#254) found new regressions, N1–N8, S1 and S2. Most come from three changes:
setTimeout(0)to a microtask. A microtask runs before Signalium's flush, and that flush is where a query whose last watcher left gets deactivated and where a Signal param change reaches it. Timing cases the timer used to absorb now surface as wrong data or as errors.component()props when every prop isObject.is-identical. Fetchium merges nestedt.objectandt.recordvalues in place and hands them out through stable wrappers, so a child given onlytoken.pricenever learned that it changed.QueryClientConfigkeys (the querycontext) could already be using.The PR keeps every perf mechanism: the microtask first fetch, sync hydration, the identical-props hash skip and request start inside
prefetch/retain. Nothing is added per render or apply. The added work is listed under Cost below.Commits and the findings they close
Probe names are from the review's probe suite. Every review probe listed fails on #71 and on the September branch, and passes here in dev and prod builds. The new maintainer tests fail without their commit; the
8b3b1b7test guards a gap that only an intermediate commit opened.26c4d4cfix(fetchium): don't turn a same-task unmount or an offline switch into a failed fetchhy-flags-timing› "activate + deactivate in one task" (no cache, cached stale);rx-navS2b, S2b-cached;hy-react-hydration› "mount and unmount in the same task (no cache)";tm-coalesce› "Signal param change and the last watcher leaving in the same task: no request";tm-reactivate› "stale reactivation and going offline in the same task"; newactivation-timing.test.tsf9a342ffix(fetchium): fetch and store the current params when a Signal param changes around a fetchhy-flags-timing› "params change in the SAME task as activation", "params Signal changes in the same task as activation";hy-react-hydration› "Signal param set in the same task as mount"; 4 more tests inactivation-timing.test.ts8b3b1b7fix(fetchium): keep a lease's release aborting the fetch it started26c4d4cretain-prefetch.test.ts: aprefetch()released in the same task still aborts its requestc91908dfix(fetchium): keep nested values reactive when they are read on their ownsg-props-hashH1d (nestedt.objectprop), H1e (t.recordprop), H1f (unconstrainedt.liveArrayprop); newnested-value-reactivity.test.ts(4) andnested-value-props.test.tsx(3)90834aetest(fetchium): benchmark reads through nested value wrapperssrc/__benchmarks__/nested-read.bench.ts, whichnpm run benchruns947658afix(fetchium): keep the query context's push hook out of Object.keystm-fetchnext-topic› "execution ctx keys visible to getTopic()"5ff7701fix(fetchium): tolerate app context values under the new config namesev-context› "a customactivitycontext key that is not an ActivitySource…", "keys the stack now consumes… still reach the context";tm-optin-edges› "reactivationStaggerMs: Infinity";ev-context-augment.test-d.ts(TS2717 before, compiles now); +3poll-activity, +2reactivation-grace3aeb7d2fix(fetchium): stop a slow suspense retry render from refetching in a loopls-react-leases› "BUG: a failed cold fetch whose React retry render takes longer than 50 ms should reach the error boundary" (one request instead of a loop); +2retain-prefetch, +1prefetch-suspense8a0de96chore(fetchium): changeset for the config-context and suspense-hold fixes5c2df8dfix(fetchium): close the timing gaps around kept, deferred and late fetchesdestroy(), stale-paramsfetchNextpage applied, async cache load for old params appliedc1-topic-same-task(later mount, cached remount),zz-vp1-sched200/zz-vp1-scheduling(busy chain),c1-stagger-aborted,c1-destroy-kept-fetch,c1-n1-fetchnext,c1-n1-async-hydrate; 7 new tests inactivation-timing.test.ts, all failing before383dcbbperf(fetchium): hand out a new wrapper for a changed nested value instead of tracking readsnested-read.bench.tsback at #71's numbers;sg-props-hashH1d/H1e/H1f still show the new value;nested-value-reactivity.test.tsupdated to the new contract;component.test.tsx's React.memo caveat test now expects the fresh value353c018chore(fetchium): changeset for the round-one timing fixes8164ca8fix(fetchium): settle every awaiter when destroy(), an offline restart or GC cancels a query's fetchdestroy()with a queued first start or restart left awaiters pending forever; a restart parked offline, then unmounted, stayed pending; with a real GC manager agcTime: 0query was collected while its kept first fetch was in flight (entities left resident, store write afterdestroy()); a topic query's first-mount awaiter waited for the next mount (pre-existing)zz-c2-settle(3),zz-c2-gc-kept(3),c1-topic-same-task› "an awaiter of the first mount settles"; 6 new tests inactivation-timing.test.ts, all failing before8f54bccfix(fetchium): keep each params key's cached data under its own recordzz-c2-n1-rootkey; 2 new tests inactivation-timing.test.ts, both failing before9c7e19efix(fetchium): reject an aborted topic fetch with an AbortError when the signal has no reasonAbortControllerpolyfill (nosignal.reason) the topicsend()rejected withundefinedzz-vf2-integ› polyfill awaiter and remount; 1 new test inactivation-timing.test.ts, failing before698eb89fix(fetchium): evict a query's previous root only while the entity map still holds it8f54bcc: the release evicted a previous root already gone from the map, which removed the new root at the same key (a reactive-kept relay after collection rebuilt the root, and its result object, on every refetch); collection also evicted a root another query with the same current params showedzz-vf2-root,zz-f4-root(2); 2 new tests inactivation-timing.test.ts, failing before0c3dd51fix(fetchium): keep a topic query pending, not rejected, when it is unmounted before its first data8164ca8: a topic query unmounted before its first data in a later task, then remounted, readisRejectedwith theAbortErroruntil the refetch landed (before8164ca8: pending)zz-vf2-integ› "unmounted in a later task…", "how long the remount reports the rejection"; 4 new tests inactivation-timing.test.ts, failing before8c75ecaperf(fetchium): merge a streamed update into a stored record without reading it back each timezz-vp3-shapesbench; 3 new tests inevent-persistence.test.ts(one read for five events, coherence after other writes, deleted record)edc6294fix(fetchium): keep any query pending, not rejected, when it is unmounted mid-fetch before its first data0c3dd51, which only held queries with a subscription): a plain REST query with no value, unmounted mid-fetch in a later task and mounted again, readisRejectedwith the deactivation'sAbortErroruntil the refetch landed (a chart switched away from a timeframe mid-fetch and back flashed its error state)zz-vc31› T1; 2 new tests inactivation-timing.test.ts(the first fails before)chore(fetchium): shorten this change's changesets for app developerschore(fetchium): rewrite this change's changesets for React developersuseQueryorfetchQuerysees; the topic same-task case below is stated as an exceptionHow the fixes work
settled()per task that starts a first fetch tracks that window. A later-task unmount still aborts. Aprefetch()/retain()release still aborts in any task.AbortError. Callers awaiting the aborted fetch still get theAbortError.deactivate()cancels a start that hasn't run yet.update().runQuery()drops a response for a storage key that is no longer current.updatedAt, so the old params' fresh timestamp can't skip the new params' fetch.383dcbb).t.objectort.recordvalue,mergeFieldsdrops the wrapper it was handed out through. The next read through the entity, which the entity's notifier already re-runs, hands out a new wrapper, so the parent passes the child a new prop and the child re-renders. Nested values the merge left unchanged keep their wrapper, so a child holding onlypriceis not re-rendered when another field changes.c91908d) gave every wrapper its own notifier and consumed it on every read. A later review measured 14–24% slower nested reads inside a computation, so383dcbbreplaced it. The review's original recipe, consuming the entity's notifier, was rejected earlier: it re-rendered the child on every unrelated field change.5c2df8d).send()waits on the subscription the deactivation tears down. Otherwise it is aborted and the next mount restarts it.settled()or onesetTimeout(0), whichever comes first.reactivationStaggerMs, an abort-rejected relay with a value is re-settled and goes through the stagger queue.destroy()aborts each query's fetch and cancels deferred work.runFetchNext()drops a page for a changed storage key. The async load path skips a load for a key that is no longer current.8164ca8).destroy()runs a queued restart against its aborted controller (it rejects), rejects a parked one, and rejects a relay left pending with no fetch at all (a first start that never ran). The client also does this for a query it collects.AbortErrorand marks the relay for a restart on the next activation, as the abort it replaced would have.runQuery()'sfinally). Reactivating clears that.TopicQueryAdapter.send()races the topic's data against the abort signal.8f54bcc).adoptParams()recomputes the query id, so the first apply after a param change builds the new params' root, andsaveQueryMetadata()points each storage key at its own record. The previous root is evicted unless another query instance shows it (a scan of the client's query instances, on a param change only).runQuery()returns a promise that never settles for that signal. The next activation's refetch replaces it, so awaiters get that result; collection anddestroy()settle it with anAbortError(abortForDestroy()already rejects a relay left pending). A same-task mount and unmount still rejects at once, so a one-off awaiter is not kept waiting. Relays with a value are unchanged.edc6294extends the unmount case to every query with no value; a pause (offline) and a parked offline restart still hold only a query with a subscription, so an awaiter of a plain query that went offline still gets theAbortErrorat once (the maintainers'network-modetest relies on it).TopicQueryAdapterrejects withgetAbortReason(signal), the helperretry()already uses for engines withoutsignal.reason.releaseQueryRoot()returns early when the map no longer holds that root, and collection goes through it for non-entity results.SyncQueryStorekeeps, per entity record it merged into (per backing kv, at most 1024, LRU), the carried field names and the rest of the record as a JSON fragment plus its references. A merge of the same fields writespayload JSON + fragmentand takes references from the__entityRefmarkers, without a read.setValue()anddeleteQuery()drop the entry.poll()uses the context'sactivityonly if it hasisActiveandsubscribefunctions. Otherwise it ignores the value and warns once per client in development.QueryContextno longer declaresactivityorpollResumeJitterMs.shouldRetry,reactivationGraceMsandreactivationStaggerMsreachthis.contextagain. A non-functionshouldRetryis ignored._notePushis non-enumerable.Verification
Round 4 (head x onto #73's new head): maintainers' suites on Signalium #254 unit 1504 passed / 2 skipped, browser 116/118 (the two #252 counts); on 3.0.3 unit 1504 / 2 skipped, browser 118/118 (2 new unit tests in
activation-timing.test.ts, the first failing before). Review probes, dev and prod, against the previous head1916164:zz-vc31T1, T4 and T5 flip to pass (T4/T5 through #73's change). Four earlier probes that encoded a plain query'sAbortErrorafter a later-task unmount now see the relay pending, as intended:tm-retry› "deactivation during a retry delay…",zz-vf2-integ› "control: a REST query in the same situation…", andzz-c2-settle› "a mount, unmount, remount and unmount in one task each" (on a node client, which never collects, the awaiter now waits for the next mount ordestroy(); with collection it settles aftergcTime). Nothing else changed. Bench (5 interleaved runs vs the previous head1916164): within noise except a full-payload update for an entity not in memory over a held record, single-class typename, −7…−14% (vp3 284k → 238k, overlapping ranges): #73 now merges such an event, cached here after its first read; the next PR writes it whole again.Round 3 (head
1916164, rebased since as8c75eca): maintainers' suites on Signalium #254 unit 1500 passed / 2 skipped, browser 116/118 (the two #252 counts); on 3.0.3 unit 1500 / 2 skipped, browser 118/118 (10 new unit tests, each failing before its fix). Review probes, dev and prod, against8f54bcc: the existing 461 node and 115 browser probes give identical results; the round-3 probes (zz-vf2-integ,zz-vf2-root,zz-f4-root) flip to pass, exceptzz-vf2-integ› "re-watched after collection", which fails the same way on #71 (residual below). Bench (7 interleaved runs of every bench, medians, 1-min load 6–21 on 18 cores; #7173c8aba→8f54bcc→ this head): a full-payload update event for an entity not in memory, on a typename two classes share, −46.6% → −32.7%, now level with the single-class case (−29.2% → −30.5%; the rest is the R7hasEntitycall and record write, which #71 does not make); the same event with the entity in memory −14.0% → −2.7%; refetch, hydrate, snapshot and event cases within noise. A 10-run A/B of the event, held-event and fetch-apply benches against8f54bcc: fetch apply ±1%, the other rows −4%…+13% with overlapping ranges.Round 2 (head
8f54bcc): maintainers' suites on Signalium #254 unit 1492 passed / 2 skipped, browser 116/118 (the two #252 counts); on 3.0.3 unit 1492 / 2 skipped, browser 118/118. Review probes, dev and prod, against949af41: the round-2 targets flip to pass (zz-c2-settle5/5,zz-c2-gc-kept3/3,zz-c2-n1-rootkey,c1-topic-same-task› "an awaiter of the first mount settles"), andguard-misc› "late-attached query extras…" andhy-shared-entity› H9/R17 go back to failing (the September branch's R17 revert in2c42511). Nothing else changed. Kit differential: as in round 1 except that oneguard-miscrow moves from "resolved" to "still failing" (unit 294 / 113 / 53 / 60 / 4; browser unchanged).zz-vp2-shared-root: no extra consumer runs, list snapshot identity kept.zz-vp2-store-ops: routed create 0kv.has, partial routed create 1, unrouted create 1. Bench (7 interleaved runs, medians vs #7173c8aba, with949af41alongside): fetch apply of 1000 entities with nested values, refetch identical −6.7% (949af41: −13.6%; an in-place parser, the ceiling, measured −3.6% in an attribution run), list membership changes −2.8% (−9.6%), every entity changed −10.3% (−11.5%; this case read −1…−10% across three sets); a shared entity's existing consumers are no longer re-run; snapshot, nested-read, refetch-list and existing-item event cases within ±4% with overlapping ranges; routed create −8% (overlapping); unrouted create −22% and an event for an entity not in memory −16% (the R7hasEntitycall and record write, inherent); hydrate with entities in memory −7% (overlapping, residual below).Round 1 (head
949af41): maintainers' suites on Signalium #254 unit 1481 passed / 2 skipped, browser 116/118 (the two #252 counts); on 3.0.3 unit 1481 / 2 skipped, browser 118/118. Probe diff against315b1ee: only the round-1 targets flipping to pass and the intended flips listed below. Bench, 7 interleaved runs against #71 (73c8aba): nested reads −2.7%…+1.8% (were −8%…−24% at315b1ee); event-born update −1.3% (was −53.5%); existing-item update −4.0% and unrouted create −16% (overlapping ranges for the first; the second is the R7hasEntitycall and the immediate eviction of what nothing routes, which #71 leaves in memory); snapshot and refetch cases within noise (a 10-run snapshot-only pass against315b1eeand947c88c: all within ±8% with overlapping ranges).Earlier numbers (head
315b1ee):Trees:
stack= #71 on Signalium #254 (the release candidate).sept= the September branch.this= this branch.sept→this):tsc: no errors outside the untracked probe files.eslint packages/fetchium/srcis clean.component.test.tsxrender countsFIX-PROMPT says to update these only if the Signalium stack ships with this release. This PR leaves them unchanged.
signalium: 3.0.3and the peer range is>=3.0.3. CI runs on 3.0.3, where they pass.expected 1 to be 2.Bench
All numbers are hz, Apple Silicon, interleaved runs, medians.
snapshot.bench.ts, 6 runs. #71 → September → this PR:nested-read.bench.ts(1000 reads per iteration), September vs this branch, 4 runs:consume()the entity proxy's owngetalready does for each field.Cost added
for…inover the params to see whether any is a Signal.settled()per task that starts a first fetch.settled()and onesetTimeout(0); whichever fires second does nothing.destroy(): one call per query instance. Per collected query: the same call.runQuery()'sfinally. Per apply: one root identity compare.hashValueof the params; when the root changes, one scan of the client's query instances.send(): one abort listener, removed when the data arrives.typeofchecks.Intended changes a client will notice
prefetch()orretain()lease still aborts.AbortErroruntil the response lands (see S2 below).useSuspenseQuery.activity,pollResumeJitterMs,shouldRetry,reactivationGraceMsandreactivationStaggerMsare reserved config names, now documented.QueryContextno longer declaresactivityorpollResumeJitterMs.reactivationGraceMs: Infinitymeans "never refetch on reactivation". It sets no timer.t.object/t.recordvalue that a merge changed is handed out through a new wrapper, sotoken.price !== oldPriceafter the price changes, and an unchanged one keeps its identity. A child (component()orReact.memo) given onlytoken.pricere-renders when the price changes, and not when another field changes. A wrapper kept from before the change still reads the merged data, but nothing that holds only that old wrapper re-runs.isRejectedwith theAbortErroruntil its data arrives (on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71 andmainit readisPendingbut never settled). The relay is not held pending here because the first-mount awaiter is a one-off read that should get theAbortErrorat once; holding it would make that awaiter wait for a remount, collection ordestroy(). The changeset states this exception.destroy()cancels everything queued: no request, subscription or store write happens after it. Whoever awaits a query it cancelled gets anAbortError, including one whose first start or restart had not run yet.AbortError(it stayed pending until the next online mount), and the next mount refetches.AbortErrorwhen a same-task unmount aborts the fetch, instead of waiting for the next mount.0c3dd51, every query sinceedc6294): its next mount reports no error while it refetches, and an awaiter gets that refetch's result, or anAbortErroron collection ordestroy(). On a server client (no collection) such an awaiter waits for a remount ordestroy(), as before8164ca8.hy-flags-timing› "params change while the first fetch is in flight" and "…a task after activation" now end with valuetwo, after['/items/1(aborted)', '/items/2'].tm-coalesce› "invalidateQueries then Signal param change in the same task" and "Signal param changes in consecutive tasks" now fetch the new params.rx-navS2d's first commit after reopening isD:-|P|E:AbortError. It wasD:-|E:AbortError.ls-leases-node› "pre-existing: a reader leaving mid-fetch…" and "prefetch ttl expiring mid-flight…": the first read is now pending.tm-destroy› "destroy() in the same task as the first activation", "…queued zero-delay refetch" and "…queued invalidation" encoded base's send through an auto-instantiated adapter afterdestroy(); nothing runs now.sg-props-hashH1d'ssameProxyIdentity,sg-hash› "…wrapped nested objects structurally" andcd-primitives-objects-records› records AFTER trace (sameAttrs) assert that a changed nested value keeps its wrapper; it now gets a new one. H1d's user-visible part (new value shown, child re-rendered) passes. In the kit's differential table this is the one extra "newly failing" node row (4 instead of 3).Residuals, deliberately left
useQueryrender that React discards, held past the 10 s render lease, still aborts its fetch. The retry render showsisPendingtogether with the oldAbortError, and the request is repeated. Probes:rx-suspenseS6a andls-react-leases› "useQuery in a render React discards".useReactiverender leases across ause()suspension, ascomponent()already does.hy-flags-timing› "params change one microtask after activation". The data is correct:two, stored only underid=2. But two requests go out (the first aborted), where the timer-based version sent one. Sending one would mean delaying every first fetch past Signalium's flush, which gives back perf(fetchium): hydrate synchronously from a sync store #69/perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.ls-leases-node› "BUG suspendOnColdMiss…".slowstep now makes 2 requests instead of 5.c1-gc-kept-fetchfails here and on every tree only because node tests get the client'sNoOpGcManager(isServeristypeof window === 'undefined'), so nothing is ever collected. With the real GC manager (zz-c2-gc-kept, and the newactivation-timingtests, which setglobalThis.window), it is fixed by8164ca8.settled()or onesetTimeout(0). In a quiet app it starts in the same task as on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71. When other signals keep Signalium's flushes chained (a ticking price feed), it starts one macrotask later than on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71 (zz-vp1-schedulingchain of 6: tick 2 vs tick 1; chain of 200: tick 1 vs tick 0), which on React Native is a native timer, possibly a frame. Starting earlier would put it before the flush's deactivations, which run after the pull phase in the same flush, and bring back the request a same-task unmount then aborts. Signalium has no public hook for "after this flush's deactivations".AsyncQueryStorewriter start-up: it readsdelegate.getAllKeys()once at construction to answerhasEntity, and keeps aSetof every held entity id. Not measured; making the scan lazy would let a queued write race it.openStartWindow()still waits onsettled(); the param-change refetch no longer does). An unmount in that time lets the first request finish instead of aborting it.has/ownKeystraps.Object.keys(items)or'0' in itemsin a child does not subscribe.length, index reads and iteration do.sg-props-hashH1a/H1b (plain props mutated in place) and H2a/H2b (element children). These are Signalium #251/#254's intended "props are immutable" contract.AsyncQueryStorewriter) are closed by the September PR.AbortErrorand readsisRejecteduntil it refetches online (pre-existing; the S2 class). The unmount case is held sinceedc6294; an awaiter of such a query now waits for the remount, collection (defaultgcTime5 min) ordestroy()instead of getting theAbortErrorat once.SyncQueryStoreover the same kv object. Writing the kv's records directly (as one maintainers' test does to seed a record) leaves an entry stale until that record is written through a store again. The next PR addsSyncQueryStore.clear()for wiping the cache through the store.reactive()kept across its query's collection is not re-registered with the client when it is watched again (zz-vf2-integ› "re-watched after collection",instancesNow0). The same on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.destroy()doesn't tear down stream subscriptions;liveValuedelete decrement isn't persisted;PauseSignalsProvideris paused is never deactivated, sogcTimenever evicts it.🤖 Generated with Claude Code