Repository navigation
perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook - #71
Merged
Merged
Conversation
abedell-phantom
force-pushed
the
perf/fast-delivery
branch
from
October 4, 2026 02:25
69d5dd8 to
eabd16d
Compare
This was referenced Oct 5, 2026
abedell-phantom
marked this pull request as ready for review
October 5, 2026 14:11
abedell-phantom
added this pull request to stack #72
October 5, 2026 19:51
This was referenced Oct 6, 2026
fix(fetchium): close the snapshot, hydration and event-persistence regressions on the perf stack
#73
Merged
Merged
…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>
…se hold failed A hold's failed fetch was kept for SUSPENSE_HOLD_TTL (10 s) whether or not a render ever claimed the error. When the suspended tree was abandoned (the user navigated away) and the fetch then failed, mounting the screen again within that window threw the stale error with no new attempt, though a later mount is promised a fresh one. React retries a suspended render within a task or two of its promise settling, so a failure no render claims within 50 ms now drops the hold. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A TopicQuery whose first fetch was aborted (a prefetch or retain released before the snapshot arrived, now routine) never loaded. Reactivation skipped reconcileSubscription() for a query with no data and restarted the fetch from inside the activating read; TopicQueryAdapter.send() then waited on a deferred that only the torn-down subscription could fulfil. A topic whose snapshot was rejected hung the same way on its next activation. runQuery() now puts the subscription back before sending, and the restart of an aborted fetch takes the relay's promise at once (so the doomed one can't settle it) but runs the subscription and the request on a microtask, or when a lease starts them, outside the read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- prefetch()'s ttl is now an upper bound. A lease whose fetch was in flight at expiry waited for it to settle, so a fetch that never settles (offline, a topic nobody fulfils) kept the query active forever. It now releases at the ttl, which aborts the fetch only when no reader has joined it. - destroy() cleared leases but not prefetch's separate ttl timer; prefetch now uses retain()'s ttl, which release() clears. - Releasing leases in destroy() deactivates their queries on Signalium's next flush, after gcManager.destroy(), and that deactivation created a fresh GC bucket interval nothing would clear. GcManager.schedule() is a no-op once destroyed. - retain() called inside a reactive computation took a new lease on every rerun. It now warns once in development, starts its fetches on their usual microtask instead of inside the computation, and releases the leases the computation's previous run took. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y opt-in Restores the previous retry default: every failed attempt is retried with the existing backoff, whatever its status, including all 4xx responses. The shouldRetry hook stays (QueryClientConfig.shouldRetry and per-query or per-mutation RetryConfig.shouldRetry, with getErrorStatus for reading a status), so an app can opt out per query where it knows a 4xx is permanent. Not retrying 4xx by default removed several seconds of retries that apps were relying on to recover queries that failed before their first load and had no other retry path. The burst of 400s that motivated the change was fixed at its source, so the default no longer needs to guard against it. defaultShouldRetry is removed (it was new in this branch); without a hook, withRetry no longer computes a status at all. 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
perf/fast-delivery
branch
from
October 6, 2026 17:01
73c8aba to
887d5f8
Compare
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ies throws Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…scheduler Options resolution now catches a getConfig() throw and returns default options plus the error, so it never throws inside a Signalium flush. The relay rejects with that error instead, and the start path skips fetching while it is set. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A config error now stops the query's running subscription before rejecting. When the error clears, the query refetches and resubscribes on its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…immediately Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
# Conflicts: # packages/fetchium/src/QueryResult.ts
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.
Why
Tapping into a screen should start its data request at the tap, and live data shouldn't do redundant work. Three gaps stood in the way:
setTimeout(0), up to a frame on React Native.watchOncereleased it as soon as the response arrived, so the screen's mount had to reactivate it.What changed
queryClient.prefetch(QueryClass, params, { ttl }): starts the query now and keeps it active forttlms (defaultDEFAULT_PREFETCH_TTL, 10 s) or until the returnedrelease()is called.queryClient.retain(fn, { ttl }): keeps the queriesfnreads or returns active untilrelease(), or forttlms. It follows Signal params.destroy()releases outstanding leases and clears their timers.useSuspenseQueryinfetchium/react: suspends only on a cold miss (no value in memory or in a sync store), never for a refetch. A failed cold fetch is thrown to the error boundary, and resetting the boundary retries it.debounce(a stale query reactivating, a Signal param change) start on a microtask. Invalidation still waits a task, so a query whose last reader left in the same task is deactivated instead of refetched.shouldRetry(error, attempt, status)hook onQueryClientConfigand onRetryConfig(per query or mutation), so an app can stop retrying an error it knows is permanent, such as a 4xx.getErrorStatusis exported. Default retry behavior is unchanged: every failed attempt is retried.RetryConfig.retriesis optional.useQuerynever suspends and reading.valuenever throws. The error-handling guide shows how to throw to an error boundary.Behavior changes / migration
shouldRetryis opt-in. When set, itsstatuscomes from the adapter's error response (ctx.response, set byRESTQueryAdapter) or fromstatus,statusCodeorresponse.statuson the thrown error.prefetch'sttlis an upper bound. When it runs out, the lease is released even with a fetch in flight, and that fetch is aborted unless a reader has joined it.isPendingmay need updating.Known limitations
retain()andprefetch()from an event handler or effect. Called inside a reactive computation, they warn in development, start fetches on the usual microtask, and keep only the latest run's leases.useSuspenseQuerycan't run inside a Signaliumcomponent(). UsefetchQuerythere.useSuspenseQueryparams to ones with no value suspends again. Wrap the change instartTransitionto keep showing the previous result.Impact
In a production React Native wallet app, this stack together with #67 and #61 is part of the build that took token-detail open from about 1.5 to 2.2 s down to about 770 ms.
Testing
retain-prefetch.test.ts: request timing, joining readers, params, sync store hits, ttl expiry mid-fetch,release(),destroy(), chained leases, Signal params, calls from a reactive computation.retry-should-retry.test.ts: the default retries every status, status read from the response or the error (and not carried over to a later network error),fetchNext, client and queryshouldRetry, mutations.read-path-latency.test.ts: cold miss, cache hit, stale and staggered reactivation and Signal param changes start without a macrotask.identical-redelivery.test.tsandreact/identical-redelivery-react.test.tsx: unchanged data re-delivered through query results, mutations, topic events and nested entities notifies nothing and re-renders nothing.react/prefetch-suspense.test.tsx: prefetch before mount, anduseSuspenseQuerycold misses, refetches, sync store hits, errors, boundary resets, abandoned trees.live-value.test.ts(a reducer that mutates in place still notifies) andtopic-query.test.ts(reactivating a topic query that never loaded).Stack: #67 → #64 → #61 → #69 → #70 → #71 (this).
🤖 Generated with Claude Code