Skip to content

perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook - #71

Merged
abedell-phantom merged 24 commits into
perf/quiet-reactivationfrom
perf/fast-delivery
Oct 8, 2026
Merged

abedell-phantom merged 24 commits into
perf/quiet-reactivationfrom
perf/fast-delivery

Conversation

@abedell-phantom

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

Copy link
Copy Markdown
Contributor

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:

  • Zero-delay refetches waited a setTimeout(0), up to a frame on React Native.
  • There was no way to start a query at tap time and keep it alive until the screen mounts. watchOnce released it as soon as the response arrived, so the screen's mount had to reactivate it.
  • A request started at tap time only went out after React's queued render.

What changed

  • queryClient.prefetch(QueryClass, params, { ttl }): starts the query now and keeps it active for ttl ms (default DEFAULT_PREFETCH_TTL, 10 s) or until the returned release() is called.
  • queryClient.retain(fn, { ttl }): keeps the queries fn reads or returns active until release(), or for ttl ms. It follows Signal params.
  • Both send their first request before returning, ahead of a render already queued on the microtask queue. A query that depends on another's result starts when that result arrives.
  • A reader that mounts while a lease is held joins the running query: no second request, and the data on its first render if it has arrived.
  • destroy() releases outstanding leases and clears their timers.
  • useSuspenseQuery in fetchium/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.
  • Refetches with no 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.
  • Adds a shouldRetry(error, attempt, status) hook on QueryClientConfig and on RetryConfig (per query or mutation), so an app can stop retrying an error it knows is permanent, such as a 4xx. getErrorStatus is exported. Default retry behavior is unchanged: every failed attempt is retried.
  • RetryConfig.retries is optional.
  • A query whose first fetch was aborted (such as a prefetch released before its topic snapshot arrived) now loads when a reader mounts. The subscription is restored before the request is sent.
  • Docs: useQuery never suspends and reading .value never throws. The error-handling guide shows how to throw to an error boundary.

Behavior changes / migration

  • None for retries: shouldRetry is opt-in. When set, its status comes from the adapter's error response (ctx.response, set by RESTQueryAdapter) or from status, statusCode or response.status on the thrown error.
  • prefetch's ttl is 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.
  • Zero-delay refetches start a macrotask earlier. Tests that wait on a timer before checking isPending may need updating.

Known limitations

  • Call retain() and prefetch() 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.
  • That reactive-caller check reads a Signalium internal. If the internal changes, the check turns off.
  • useSuspenseQuery can't run inside a Signalium component(). Use fetchQuery there.
  • Changing useSuspenseQuery params to ones with no value suspends again. Wrap the change in startTransition to 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

  • New 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.
  • New 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 query shouldRetry, mutations.
  • New read-path-latency.test.ts: cold miss, cache hit, stale and staggered reactivation and Signal param changes start without a macrotask.
  • New identical-redelivery.test.ts and react/identical-redelivery-react.test.tsx: unchanged data re-delivered through query results, mutations, topic events and nested entities notifies nothing and re-renders nothing.
  • New react/prefetch-suspense.test.tsx: prefetch before mount, and useSuspenseQuery cold misses, refetches, sync store hits, errors, boundary resets, abandoned trees.
  • Additions to live-value.test.ts (a reducer that mutates in place still notifies) and topic-query.test.ts (reactivating a topic query that never loaded).

Stack: #67 → #64 → #61 → #69 → #70 → #71 (this).

🤖 Generated with Claude Code

@abedell-phantom
abedell-phantom marked this pull request as ready for review October 5, 2026 14:11
@abedell-phantom
abedell-phantom requested review from a team and pzuraq as code owners October 5, 2026 14:11
@abedell-phantom abedell-phantom changed the title perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, no 4xx retries perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook Oct 5, 2026
@abedell-phantom
abedell-phantom added this pull request to stack #72 October 5, 2026 19:51
abedell-phantom and others added 11 commits October 6, 2026 13:00
…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>
Comment thread packages/fetchium/src/QueryClient.ts Outdated
abedell-phantom and others added 5 commits October 6, 2026 19:40
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>
abedell-phantom and others added 3 commits October 7, 2026 00:17
…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>
Comment thread packages/fetchium/src/MutationResult.ts
abedell-phantom and others added 3 commits October 7, 2026 18:42
@abedell-phantom
abedell-phantom merged commit 82d67fc into main Oct 8, 2026
2 checks passed
@abedell-phantom
abedell-phantom deleted the perf/fast-delivery branch October 8, 2026 00:07
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.

3 participants