Skip to content

fix(fetchium): activation timing, nested-value reactivity and config fixes on the perf stack - #74

Merged
abedell-phantom merged 37 commits into
fix/perf-stack-regressionsfrom
fix/stack-timing-and-reactivity
Oct 8, 2026
Merged

abedell-phantom merged 37 commits into
fix/perf-stack-regressionsfrom
fix/stack-timing-and-reactivity

Conversation

@abedell-phantom

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

Copy link
Copy Markdown
Contributor

Base: fix/perf-stack-regressions (#73, the September fix, 3fa63ba, on top of #71). Branch: fix/stack-timing-and-reactivity, head 4d34bc3. 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:

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 8b3b1b7 test guards a gap that only an intermediate commit opened.

commit finding probes / tests
26c4d4c fix(fetchium): don't turn a same-task unmount or an offline switch into a failed fetch N2 (both parts) and N3 hy-flags-timing › "activate + deactivate in one task" (no cache, cached stale); rx-nav S2b, 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"; new activation-timing.test.ts
f9a342f fix(fetchium): fetch and store the current params when a Signal param changes around a fetch N1, plus the older mid-flight variant hy-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 in activation-timing.test.ts
8b3b1b7 fix(fetchium): keep a lease's release aborting the fetch it started follow-up to 26c4d4c retain-prefetch.test.ts: a prefetch() released in the same task still aborts its request
c91908d fix(fetchium): keep nested values reactive when they are read on their own S1 sg-props-hash H1d (nested t.object prop), H1e (t.record prop), H1f (unconstrained t.liveArray prop); new nested-value-reactivity.test.ts (4) and nested-value-props.test.tsx (3)
90834ae test(fetchium): benchmark reads through nested value wrappers S1 cost src/__benchmarks__/nested-read.bench.ts, which npm run bench runs
947658a fix(fetchium): keep the query context's push hook out of Object.keys N8 tm-fetchnext-topic › "execution ctx keys visible to getTopic()"
5ff7701 fix(fetchium): tolerate app context values under the new config names N4, N5, N7 ev-context › "a custom activity context 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); +3 poll-activity, +2 reactivation-grace
3aeb7d2 fix(fetchium): stop a slow suspense retry render from refetching in a loop N6 (the loop) ls-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); +2 retain-prefetch, +1 prefetch-suspense
8a0de96 chore(fetchium): changeset for the config-context and suspense-hold fixes
5c2df8d fix(fetchium): close the timing gaps around kept, deferred and late fetches round-1 review of this branch: topic query stuck pending after a same-task mount/unmount (blocker), param-change refetch starved by chained flushes, stagger bypassed for pause-aborted refetches, kept first fetch writing after destroy(), stale-params fetchNext page applied, async cache load for old params applied c1-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 in activation-timing.test.ts, all failing before
383dcbb perf(fetchium): hand out a new wrapper for a changed nested value instead of tracking reads round-1: S1's per-read cost (14–24% slower nested reads inside a computation) nested-read.bench.ts back at #71's numbers; sg-props-hash H1d/H1e/H1f still show the new value; nested-value-reactivity.test.ts updated to the new contract; component.test.tsx's React.memo caveat test now expects the fresh value
353c018 chore(fetchium): changeset for the round-one timing fixes
8164ca8 fix(fetchium): settle every awaiter when destroy(), an offline restart or GC cancels a query's fetch round-2 review: destroy() with a queued first start or restart left awaiters pending forever; a restart parked offline, then unmounted, stayed pending; with a real GC manager a gcTime: 0 query was collected while its kept first fetch was in flight (entities left resident, store write after destroy()); 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 in activation-timing.test.ts, all failing before
8f54bcc fix(fetchium): keep each params key's cached data under its own record round-2 review, pre-existing in every version (inside N1's scope): a non-entity result's root entity was keyed by the params the instance was created with, so after a Signal param change every key's record pointed at the newest params' data, and another query showing the old params was overwritten in memory zz-c2-n1-rootkey; 2 new tests in activation-timing.test.ts, both failing before
9c7e19e fix(fetchium): reject an aborted topic fetch with an AbortError when the signal has no reason round-3 review: under React Native's AbortController polyfill (no signal.reason) the topic send() rejected with undefined zz-vf2-integ › polyfill awaiter and remount; 1 new test in activation-timing.test.ts, failing before
698eb89 fix(fetchium): evict a query's previous root only while the entity map still holds it round-3 review of 8f54bcc: 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 showed zz-vf2-root, zz-f4-root (2); 2 new tests in activation-timing.test.ts, failing before
0c3dd51 fix(fetchium): keep a topic query pending, not rejected, when it is unmounted before its first data round-3 review of 8164ca8: a topic query unmounted before its first data in a later task, then remounted, read isRejected with the AbortError until the refetch landed (before 8164ca8: pending) zz-vf2-integ › "unmounted in a later task…", "how long the remount reports the rejection"; 4 new tests in activation-timing.test.ts, failing before
8c75eca perf(fetchium): merge a streamed update into a stored record without reading it back each time round-3 perf review: a full-payload update for an entity not in memory, on a typename two classes share, read and parsed the stored record on every event (−41% vs #71, single-class −18%) zz-vp3-shapes bench; 3 new tests in event-persistence.test.ts (one read for five events, coherence after other writes, deleted record)
edc6294 fix(fetchium): keep any query pending, not rejected, when it is unmounted mid-fetch before its first data round-4 review, pre-existing (left as is by 0c3dd51, which only held queries with a subscription): a plain REST query with no value, unmounted mid-fetch in a later task and mounted again, read isRejected with the deactivation's AbortError until the refetch landed (a chart switched away from a timeframe mid-fetch and back flashed its error state) zz-vc31 › T1; 2 new tests in activation-timing.test.ts (the first fails before)
chore chore(fetchium): shorten this change's changesets for app developers the four changesets rewritten as short, user-facing bullets; detail stays in this description
chore chore(fetchium): rewrite this change's changesets for React developers the four changesets rewritten again, leading with what an app using useQuery or fetchQuery sees; the topic same-task case below is stated as an exception

How the fixes work

  • N2: mount and unmount in one task.
    • The first request is left to finish in the background, as the timer-started one did, and the next mount shows its data.
    • One settled() per task that starts a first fetch tracks that window. A later-task unmount still aborts. A prefetch()/retain() release still aborts in any task.
    • We didn't use early returns because the microtask always runs before the flush that deactivates the query, so they could not stop that request.
    • The probes also show the timer-based version sent this request and let it finish, so that is the behaviour we keep.
  • N2, Signal param change in the task the last watcher leaves. The param-change refetch waits for Signalium's flush that is already running, so the deactivation cancels it and no request goes out.
  • N2 part 2. A relay that a deactivation's abort settled as rejected is restarted on the next activation. A cached value is re-settled first, so it shows without the AbortError. Callers awaiting the aborted fetch still get the AbortError.
  • N3. The deferred start, the zero-delay and debounced refetches and the deferred restart return early unless the query is active and online. deactivate() cancels a start that hasn't run yet.
  • N1.
    • The deferred first start re-reads Signal params, only for queries that have any.
    • A param change while a fetch is in flight or queued aborts that fetch. The relay takes the new promise right away, and the new request starts after the flush, outside update().
    • runQuery() drops a response for a storage key that is no longer current.
    • A param change resets updatedAt, so the old params' fresh timestamp can't skip the new params' fetch.
  • S1 (as reworked in 383dcbb).
    • When an in-place merge changes a nested t.object or t.record value, mergeFields 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 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 only price is not re-rendered when another field changes.
    • Entity wrappers use the shared handlers again and consume nothing, so reads cost what they did on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.
    • A live collection's value changes in place under the collection's notifier, so its wrappers consume that notifier (one that already exists).
    • The first version (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, so 383dcbb replaced it. The review's original recipe, consuming the entity's notifier, was rejected earlier: it re-rendered the child on every unrelated field change.
    • Signalium's hash skip is untouched.
  • Round-1 timing fixes (5c2df8d).
    • A same-task first fetch is kept only when no subscription is running: a topic query's send() waits on the subscription the deactivation tears down. Otherwise it is aborted and the next mount restarts it.
    • The param-change refetch waits for settled() or one setTimeout(0), whichever comes first.
    • With 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.
  • Round-2 settle and GC fixes (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.
    • A restart that finds the query offline keeps its rejection. Coming back online still restarts it; a full deactivation first rejects it with an AbortError and marks the relay for a restart on the next activation, as the abort it replaced would have.
    • A first fetch kept by a same-task unmount defers the query's collection until it settles (one signal compare in runQuery()'s finally). Reactivating clears that.
    • TopicQueryAdapter.send() races the topic's data against the abort signal.
  • Root entity per params (8f54bcc). adoptParams() recomputes the query id, so the first apply after a param change builds the new params' root, and saveQueryMetadata() 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).
  • Round-3 fixes.
    • A deactivation that aborts the fetch of a query with a subscription and no value yet, in a later task than its mount (or while offline), leaves the relay pending: runQuery() returns a promise that never settles for that signal. The next activation's refetch replaces it, so awaiters get that result; collection and destroy() settle it with an AbortError (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. edc6294 extends 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 the AbortError at once (the maintainers' network-mode test relies on it).
    • TopicQueryAdapter rejects with getAbortReason(signal), the helper retry() already uses for engines without signal.reason.
    • releaseQueryRoot() returns early when the map no longer holds that root, and collection goes through it for non-entity results.
    • SyncQueryStore keeps, 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 writes payload JSON + fragment and takes references from the __entityRef markers, without a read. setValue() and deleteQuery() drop the entry.
  • N4, N5, N7, N8.
    • poll() uses the context's activity only if it has isActive and subscribe functions. Otherwise it ignores the value and warns once per client in development.
    • QueryContext no longer declares activity or pollResumeJitterMs.
    • shouldRetry, reactivationGraceMs and reactivationStaggerMs reach this.context again. A non-function shouldRetry is ignored.
    • A non-finite stagger or jitter counts as 0.
    • _notePush is non-enumerable.
  • N6.
    • An error no render has claimed now waits 1 s instead of 50 ms.
    • If that expires and the next attempt also fails, its error waits 10 s.
    • A claimed error is still released on the next task, so an error-boundary reset retries.

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 head 1916164: zz-vc31 T1, T4 and T5 flip to pass (T4/T5 through #73's change). Four earlier probes that encoded a plain query's AbortError after 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…", and zz-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 or destroy(); with collection it settles after gcTime). Nothing else changed. Bench (5 interleaved runs vs the previous head 1916164): 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 as 8c75eca): 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, against 8f54bcc: 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, except zz-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; #71 73c8aba → 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 R7 hasEntity call 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 against 8f54bcc: 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, against 949af41: the round-2 targets flip to pass (zz-c2-settle 5/5, zz-c2-gc-kept 3/3, zz-c2-n1-rootkey, c1-topic-same-task › "an awaiter of the first mount settles"), and guard-misc › "late-attached query extras…" and hy-shared-entity › H9/R17 go back to failing (the September branch's R17 revert in 2c42511). Nothing else changed. Kit differential: as in round 1 except that one guard-misc row 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 0 kv.has, partial routed create 1, unrouted create 1. Bench (7 interleaved runs, medians vs #71 73c8aba, with 949af41 alongside): 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 R7 hasEntity call 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 against 315b1ee: 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% at 315b1ee); 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 R7 hasEntity call 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 against 315b1ee and 947c88c: 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.

  • Maintainers' suites (unit and browser):
  • Review probes (84 files, dev and prod, sept → this):
    • Node: 16 changes per build. Browser: 9 changes per build.
    • Every change is either a targeted probe flipping fail → pass or one of the expectation changes listed under "Intended changes" below. Nothing else changed.
    • Totals: node dev 333 → 337/433, prod 334 → 338. Browser dev 71 → 78/115, prod 75 → 82.
  • The September fix's differential suite: still byte-identical to its reference tables.
    • Node: 294 / 113 / 54 / 59 / 3.
    • Browser: 50 / 14 / 0 / 14 / 2.
  • tsc: no errors outside the untracked probe files.
  • eslint and prettier: clean on every changed file. eslint packages/fetchium/src is clean.

component.test.tsx render counts

FIX-PROMPT says to update these only if the Signalium stack ships with this release. This PR leaves them unchanged.

  • Fetchium's devDependency is signalium: 3.0.3 and the peer range is >=3.0.3. CI runs on 3.0.3, where they pass.
  • Against Signalium #252's single mount render, "Entity Updates and Reactivity" › "should update component when entity data changes" and "should not rerender parent components when only child components change" read expected 1 to be 2.
  • Update them when the devDependency moves to a Signalium release that includes #252.

Bench

All numbers are hz, Apple Silicon, interleaved runs, medians.

snapshot.bench.ts, 6 runs. #71 → September → this PR:

case #71 sept this this vs #71
100 entities, one field changes 72.4k 73.2k 71.1k −1.7%
100 entities, identical event 1.23M 1.27M 1.21M −2.0%
1000 entities, one field changes 8.2k 8.6k 8.3k +0.9%
1000 entities, identical event 1.21M 1.26M 1.36M +12.5%
cold query 100 730 739 685 −6.2%
cold query 1000 132 124 126 −4.3%
  • The cold rows are ±10–15% rme.
  • An extra 8 interleaved cold-only runs, September vs this branch, gave medians of 722 vs 717 (100) and 128 vs 130 (1000). That is noise.
  • The snapshot and apply paths only do new work when a nested value actually changed.

nested-read.bench.ts (1000 reads per iteration), September vs this branch, 4 runs:

  • Outside a computation, reads through a nested wrapper cost about 1–5 ns more per read.
  • Inside a computation, a read records one dependency, about 4–7 ns per read. That is the same consume() the entity proxy's own get already does for each field.
  • An entity's top-level field read is unchanged.

Cost added

  • Per query construction: a for…in over the params to see whether any is a Signal.
  • Per microtask first start: a counter compare, two field writes, and one settled() per task that starts a first fetch.
  • Per response: one int compare (the storage key).
  • Per param change: one settled() and one setTimeout(0); whichever fires second does nothing.
  • Per nested read through an entity: nothing. A read through a live collection's value consumes the collection's notifier.
  • Per changed nested merge: one WeakMap delete. That map replaces the old proxy cache, so there is no second map.
  • Per destroy(): one call per query instance. Per collected query: the same call.
  • Per fetch: one signal compare in runQuery()'s finally. Per apply: one root identity compare.
  • Per Signal param change: one hashValue of the params; when the root changes, one scan of the client's query instances.
  • Per topic send(): one abort listener, removed when the data arrives.
  • Per poll subscription: two typeof checks.
  • Round 3: per deactivation that aborts a pending fetch, one config read; per entity write, one map-size check (a map delete once anything was merged); per merge, a string of the carried field names and one regex scan of the payload's JSON.
  • Failure paths only: the suspense hold bookkeeping.

Intended changes a client will notice

  • Mount and unmount in one task. The first request finishes in the background instead of being aborted, and the next mount shows its data, as it did before perf(fetchium): hydrate synchronously from a sync store #69. Releasing a prefetch() or retain() lease still aborts.
  • Param change while a fetch is in flight. The fetch is aborted and the new params are fetched: one extra request, replacing a response that was wrong before. Previously, every version showed and persisted the old params' response under the new key.
  • A param change marks the old data stale.
  • Reopening after a deactivation aborted the fetch. The fetch restarts immediately. With a cached value, no error is shown. With no value yet, an unmount in a later task than the mount no longer rejects the relay (below), so there is no error to report; after a same-task unmount, a pause or a parked offline restart, the relay is pending again but still reports the previous AbortError until the response lands (see S2 below).
  • useSuspenseQuery.
    • A tree abandoned while suspended and mounted again within 1 s of its fetch failing shows that error instead of trying again.
    • Mounted later, it makes a new attempt.
    • The existing test for this case now remounts after 1150 ms instead of 150 ms. Its assertions are unchanged.
  • Config names.
    • activity, pollResumeJitterMs, shouldRetry, reactivationGraceMs and reactivationStaggerMs are reserved config names, now documented.
    • QueryContext no longer declares activity or pollResumeJitterMs.
    • reactivationGraceMs: Infinity means "never refetch on reactivation". It sets no timer.
  • Nested values. A nested t.object/t.record value that a merge changed is handed out through a new wrapper, so token.price !== oldPrice after the price changes, and an unchanged one keeps its identity. A child (component() or React.memo) given only token.price re-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.
  • A topic query (or any query with a running subscription) mounted and unmounted in one task has its first fetch aborted, as before 0.6, and the next mount restarts it. With no data yet, that next mount reads isRejected with the AbortError until its data arrives (on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71 and main it read isPending but never settled). The relay is not held pending here because the first-mount awaiter is a one-off read that should get the AbortError at once; holding it would make that awaiter wait for a remount, collection or destroy(). 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 an AbortError, including one whose first start or restart had not run yet.
  • A restart that found the network offline, then an unmount: the relay rejects with an AbortError (it stayed pending until the next online mount), and the next mount refetches.
  • A topic query's first-mount awaiter gets an AbortError when a same-task unmount aborts the fetch, instead of waiting for the next mount.
  • Any query unmounted mid-fetch before its first data, in a later task, stays pending instead of rejecting (topic queries since 0c3dd51, every query since edc6294): its next mount reports no error while it refetches, and an awaiter gets that refetch's result, or an AbortError on collection or destroy(). On a server client (no collection) such an awaiter waits for a remount or destroy(), as before 8164ca8.
  • A non-entity query after a Signal param change hands out a new result object for the new params (it used to merge them into the old params' object). The old params' data stays cached under their own key.
  • Review probes whose expectations need updating. They asserted the stack's old bug or its exact request count; updating them is not weakening:
    • hy-flags-timing › "params change while the first fetch is in flight" and "…a task after activation" now end with value two, 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-nav S2d's first commit after reopening is D:-|P|E:AbortError. It was D:-|E:AbortError.
    • ls-leases-node › "pre-existing: a reader leaving mid-fetch…" and "prefetch ttl expiring mid-flight…": the first read is now pending.
    • Round 1: 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 after destroy(); nothing runs now.
    • Round 1: sg-props-hash H1d's sameProxyIdentity, sg-hash › "…wrapped nested objects structurally" and cd-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

  • S2 is not fixed.
    • What happens: a useQuery render that React discards, held past the 10 s render lease, still aborts its fetch. The retry render shows isPending together with the old AbortError, and the request is repeated. Probes: rx-suspense S6a and ls-react-leases › "useQuery in a render React discards".
    • Why Fetchium can't clear it: Signalium keeps a rejected relay's error while it is pending again.
    • Real fixes, in order of preference:
      1. Signalium pins useReactive render leases across a use() suspension, as component() already does.
      2. Signalium clears a relay's error when a new promise is set.
      3. Fetchium stops aborting a first fetch on deactivation. That is a product change: "back while loading aborts".
  • hy-flags-timing › "params change one microtask after activation". The data is correct: two, stored only under id=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.
  • Reactivate, then deactivate again in the same task. The request is still sent and then aborted. The keep-alive window covers only the first fetch.
  • ls-leases-node › "BUG suspendOnColdMiss…".
    • The probe calls again 70 ms after the error was claimed and expects the error again.
    • The maintainers' test "retries a failed cold fetch when the error boundary resets" makes the same call 10–60 ms after the claim and needs a new attempt.
    • The client can't tell those two calls apart, and in React a render after the claim is a fresh mount. The probe's slow step now makes 2 requests instead of 5.
  • c1-gc-kept-fetch fails here and on every tree only because node tests get the client's NoOpGcManager (isServer is typeof window === 'undefined'), so nothing is ever collected. With the real GC manager (zz-c2-gc-kept, and the new activation-timing tests, which set globalThis.window), it is fixed by 8164ca8.
  • A kept first fetch that never settles (an adapter with no timeout that ignores the abort) keeps its query from being collected until it settles or the client is destroyed.
  • Param change under a busy flush chain. The param-change refetch waits for settled() or one setTimeout(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-scheduling chain 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".
  • Caches written by earlier versions for a Signal-param query keep their shared root record until each params key is fetched again.
  • Hydrating a cached query whose entities are already in memory measured −8…−14% against perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71 in the second perf review, with overlapping ranges; the structural check that replaced perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71's (corrupting) re-parse runs per parse. Not changed.
  • AsyncQueryStore writer start-up: it reads delegate.getAllKeys() once at construction to answer hasEntity, and keeps a Set of every held entity id. Not measured; making the scan lazy would let a queued write race it.
  • Timing edges.
    • If Signalium flushes run back to back, the same-task window stays open longer (openStartWindow() still waits on settled(); the param-change refetch no longer does). An unmount in that time lets the first request finish instead of aborting it.
  • Live-collection arrays have no has/ownKeys traps. Object.keys(items) or '0' in items in a child does not subscribe. length, index reads and iteration do.
  • Signalium-side probes outside S1: sg-props-hash H1a/H1b (plain props mutated in place) and H2a/H2b (element children). These are Signalium #251/#254's intended "props are immutable" contract.
  • Tier C.
    • R9–R12 (the AsyncQueryStore writer) are closed by the September PR.
    • N6's prefetch-TTL case is covered by N2 part 2, with the same caveat as S2 when there is no value yet.
  • Round 3 leftovers.
    • A query without a subscription that goes offline mid-fetch (a pause) with no value still rejects its awaiter with the AbortError and reads isRejected until it refetches online (pre-existing; the S2 class). The unmount case is held since edc6294; an awaiter of such a query now waits for the remount, collection (default gcTime 5 min) or destroy() instead of getting the AbortError at once.
    • The per-event merge read is fixed in this PR's store change; fix(fetchium): close the snapshot, hydration and event-persistence regressions on the perf stack #73 on its own still pays it.
    • The merge entries assume records are written through a SyncQueryStore over 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 adds SyncQueryStore.clear() for wiping the cache through the store.
    • A relay a 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", instancesNow 0). The same on perf(fetchium): microtask refetches, prefetch/retain, sync lease starts, shouldRetry hook #71.
  • Pre-existing in every version, not changed here. Two older bugs are now fixed: a param change mid-flight persisting the wrong data (N1), and reopening after backing out of a loading screen when a cached value exists (N2 part 2). These remain:
    • offline → online with fresh data never restores the subscription;
    • destroy() doesn't tear down stream subscriptions;
    • a liveValue delete decrement isn't persisted;
    • a query whose only reader unmounts while its PauseSignalsProvider is paused is never deactivated, so gcTime never evicts it.

🤖 Generated with Claude Code

abedell-phantom and others added 21 commits October 6, 2026 13:00
…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>
Comment thread packages/fetchium/src/QueryResult.ts Outdated
abedell-phantom and others added 13 commits October 6, 2026 19:41
…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>
jimmy-phantom
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>
@abedell-phantom
abedell-phantom merged commit a23ad4b into main Oct 8, 2026
1 check passed
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