Skip to content

fix(core): Hold fetch results until init() while DataProvider mounts - #4097

Open
ntucker wants to merge 25 commits into
masterfrom
cursor/precommit-dispatch-hold-6fdf
Open

ntucker wants to merge 25 commits into
masterfrom
cursor/precommit-dispatch-hold-6fdf

Conversation

@ntucker

@ntucker ntucker commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Requested by Nathaniel · project thread

Motivation

useSuspense and use(useFetch()) can stay on the fallback when their fetch finishes before DataProvider commits. React throws the uncommitted store away, so the SET_RESPONSE lands nowhere. A shared NetworkManager then keeps a promise that never settles, and fresh managers refetch in a loop.

Two shapes cause it:

  • Something outside the provider suspends during the first render, such as a sibling use().
  • The provider is inside a React.lazy layout. Expo Router's root layout works this way. The provider's first render is then a retry of a boundary that already showed its fallback, and the fetch's own update restarts that render.

Solution

A commit gate in NetworkManager. It starts open, and only DataProvider closes it.

  • Controller gets an internal awaitingInit flag. DataProvider sets it on the controller it creates, and initManager clears it before calling each manager's init(). It is cleared by assigning false, not with delete, so the controller keeps V8's fast property layout.
  • While the flag is set, a throttled fetch that settles parks its resolve arguments on its in-flight FetchingMeta record instead of dispatching.
  • NetworkManager.init() publishes parked results from a microtask, through controller.resolve. Publishing synchronously would run inside React's passive effect flush, where test controllers that wrap resolve in act() re-enter React.
  • Parked results live on the record, so RESET and clear() drop them with no extra code.
  • Mutations resolve immediately, as before. Redux, Vue, and the server stores never set the flag.
  • useEnhancedReducer and usePromisifiedDispatch are unchanged from master.

Tests

  • The pre-commit race suite (precommit-race.node-suite.tsx) runs on React DOM and React Native hosts, and on React 17, 18, and 19. It covers a blocker outside and inside the provider, a lazy layout around DataProvider with and without a transition, rejected fetches, StrictMode, and a provider that settles inside a hidden <Activity> and is then revealed. On React 17 and 18, the use(), transition, and Activity cases skip where unsupported. React 17's legacy root has no race.
  • With master's NetworkManager, initManager, DataProvider, and Controller, 22 of the 30 race tests fail on React 19.2.3 (DOM and Native). With this PR, all 30 pass.
  • NetworkManager unit tests drive the gate through initManager. They cover parking, publishing from a microtask, publishing to the controller bound at init(), a same-turn init → cleanup → init, a middleware that throws during publish, mutations, and RESET.
  • Full coverage run on React 19.2.3 (ReactDOM + Node): 119 suites, 1715 passed, 2 skipped, coverage thresholds met. CircleCI is green on ^17, ^18, native, latest, and 19.3.

Bundle size

From the compressed-size bot on this PR:

File Change
rdcClient.js +157 B
rdcNextjs.js +160 B
rdcPipeableStream.js +153 B

Performance

Same machine, interleaved, 5 rounds after a discarded warmup pair, on an Intel(R) Xeon(R) Processor.

No suite is outside that noise: every min–max range overlaps. Ratio is PR median ÷ master median (above 1 is faster). Spread is master / PR, (max − min) / median. The median of the ratios is 0.98 on the React hot path and 1.00 on Node core.

React hot path:

Suite Master PR Ratio Spread
getlist-100 196.08 186.93 0.95 17% / 19%
getlist-500 55.50 57.98 1.04 8% / 18%
update-entity 625 555.56 0.89 19% / 6%
update-user 606.62 555.56 0.92 29% / 25%
getlist-500-sorted 52.30 58.49 1.12 20% / 22%
update-entity-sorted 488.10 476.19 0.98 15% / 26%
update-entity-multi-view 555.56 526.32 0.95 13% / 19%
list-detail-switch-10 15.43 16.16 1.05 11% / 15%
update-user-10000 104.71 111.74 1.07 18% / 20%
invalidate-and-resolve 54.79 54.35 0.99 6% / 7%
unshift-item 370.37 339.08 0.92 11% / 21%
delete-item 476.19 425.72 0.89 5% / 42%
move-item 289.92 294.12 1.01 18% / 21%

Node core:

Suite Master PR Ratio Spread
getResponse 5,295 5,444 1.03 41% / 9%
getResponse (null) 12,408,012 13,881,195 1.12 33% / 23%
getResponse (clear cache) 271 271 1.00 33% / 19%
getSmallResponse 4,134 4,630 1.12 24% / 21%
getSmallInferredResponse 3,410 3,676 1.08 41% / 26%
getResponse Collection 4,443 4,506 1.01 33% / 20%
get Collection 3,364 3,631 1.08 39% / 15%
get Query-sorted 5,547 5,223 0.94 15% / 18%
setLong 455 444 0.98 26% / 32%
setLongWithMerge 254 254 1.00 29% / 20%
setLongWithSimpleMerge 306 287 0.94 20% / 25%
setSmallResponse 500x 1,115 1,006 0.90 29% / 43%
setMany 50x one-per-row 162 148 0.91 17% / 30%
setMany 50 batch 2,438 2,331 0.96 32% / 44%
setMany 500x one-per-row 17.29 16.77 0.97 8% / 28%
setMany 500 batch 1,121 1,111 0.99 10% / 35%

The Benchmark React bot comment compares runs on different runners (Reading the CI comment).

Notes

  • A DataProvider inside a hidden <Activity> commits without running effects, so its results stay parked until it is shown. The race suite tests this: the value or error appears on reveal with one endpoint call.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k


Note

Medium Risk
Changes core fetch-to-store timing for React Suspense paths; behavior is gated on awaitingInit and covered by new race tests, but subtle timing around init/cleanup/RESET could affect edge cases.

Overview
Fixes Suspense stuck on fallback (and refetch loops) when a read fetch finishes before DataProvider commits—e.g. a sibling use() suspends the tree, or the provider lives inside a React.lazy layout (Expo Router).

DataProvider sets controller.awaitingInit = true on the controller it creates; initManager clears it before managers run init(). While that flag is set, NetworkManager does not dispatch SET_RESPONSE for throttled (non-mutation) fetches: it parks the resolution on the in-flight FetchingMeta and flushes it from a microtask in init() via new publish / settle / publishParked paths. Parked work survives cleanup() until the next init(), is dropped on RESET, and middleware throws during publish are turned into fetch errors. Mutations still resolve immediately.

Adds NetworkManager gate tests and a shared precommit-race suite (DOM + Native) covering lazy layouts, transitions, errors, StrictMode, and hidden <Activity>.

Reviewed by Cursor Bugbot for commit 74600bd. Bugbot is set up for automated code reviews on this repo. Configure here.

@changeset-bot

changeset-bot Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 74600bd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@data-client/core Patch
@data-client/react Patch
example-benchmark Patch
example-benchmark-react Patch
test-bundlesize Patch
coinbase-lite Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs-site Ready Ready Preview Oct 21, 2026 4:33am UTC

Request Review

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Size Change: +458 B (+0.41%)

Total Size: 112 kB

📦 View Changed
Filename Size Change
examples/test-bundlesize/dist/rdcClient.js 11 kB +154 B (+1.42%)
examples/test-bundlesize/dist/rdcNextjs.js 12.5 kB +155 B (+1.25%)
examples/test-bundlesize/dist/rdcPipeableStream.js 9.8 kB +149 B (+1.54%)
ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.45 kB
examples/test-bundlesize/dist/polyfill.js 301 B
examples/test-bundlesize/dist/rdcEndpoint.js 8.04 kB
examples/test-bundlesize/dist/react.js 68.6 kB
examples/test-bundlesize/dist/webpack-runtime.js 698 B

compressed-size-action

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark React

Details
Benchmark suite Current: 74600bd Previous: b091c5f Ratio
data-client: getlist-100 229.92 ops/s (± 6.9%) 238.1 ops/s (± 10.3%) 1.04
data-client: getlist-500 80 ops/s (± 4.3%) 79.37 ops/s (± 4.9%) 0.99
data-client: update-entity 454.55 ops/s (± 7.8%) 625 ops/s (± 9.0%) 1.37
data-client: update-user 500 ops/s (± 8.6%) 690.48 ops/s (± 7.4%) 1.38
data-client: getlist-500-sorted 71.98 ops/s (± 10.6%) 69.5 ops/s (± 10.0%) 0.97
data-client: update-entity-sorted 400 ops/s (± 9.7%) 526.32 ops/s (± 6.2%) 1.32
data-client: update-entity-multi-view 526.32 ops/s (± 11.5%) 588.24 ops/s (± 9.2%) 1.12
data-client: list-detail-switch-10 25.97 ops/s (± 6.6%) 20 ops/s (± 6.5%) 0.77
data-client: update-user-10000 131.58 ops/s (± 12.8%) 143.89 ops/s (± 11.9%) 1.09
data-client: invalidate-and-resolve 60.98 ops/s (± 5.8%) 65.36 ops/s (± 5.9%) 1.07
data-client: unshift-item 344.83 ops/s (± 1.8%) 400 ops/s (± 5.5%) 1.16
data-client: delete-item 488.1 ops/s (± 8.2%) 588.24 ops/s (± 9.5%) 1.21
data-client: move-item 256.41 ops/s (± 7.2%) 322.58 ops/s (± 8.0%) 1.26

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: 74600bd Previous: b091c5f Ratio
normalizeLong 430 ops/sec (±4.28%) 423 ops/sec (±4.54%) 0.98
normalizeLong Values 385 ops/sec (±0.35%) 389 ops/sec (±1.29%) 1.01
normalizeLong Scalar 361 ops/sec (±2.75%) 363 ops/sec (±3.38%) 1.01
normalizeLong Scalar update 912 ops/sec (±0.15%) 890 ops/sec (±0.85%) 0.98
denormalizeLong 226 ops/sec (±6.87%) 234 ops/sec (±6.81%) 1.04
denormalizeLong Values 223 ops/sec (±5.47%) 224 ops/sec (±5.19%) 1.00
denormalizeLong donotcache 994 ops/sec (±0.28%) 980 ops/sec (±0.79%) 0.99
denormalizeLong Values donotcache 734 ops/sec (±0.54%) 730 ops/sec (±0.57%) 0.99
denormalizeLong Scalar donotcache 1020 ops/sec (±0.74%) 1056 ops/sec (±0.49%) 1.04
denormalizeShort donotcache 500x 1435 ops/sec (±0.07%) 1435 ops/sec (±0.14%) 1
denormalizeShort 500x 649 ops/sec (±6.55%) 642 ops/sec (±7.67%) 0.99
denormalizeShort 500x withCache 6921 ops/sec (±0.86%) 6716 ops/sec (±0.34%) 0.97
queryShort 500x withCache 3210 ops/sec (±0.09%) 3000 ops/sec (±0.14%) 0.93
buildQueryKey All 59757 ops/sec (±0.64%) 57896 ops/sec (±1.22%) 0.97
query All withCache 6273 ops/sec (±2.14%) 6012 ops/sec (±2.71%) 0.96
denormalizeLong with mixin Entity 208 ops/sec (±7.52%) 212 ops/sec (±6.14%) 1.02
denormalizeLong withCache 6229 ops/sec (±0.37%) 6567 ops/sec (±0.29%) 1.05
denormalizeLong withCache (Scalar churn) 6241 ops/sec (±0.23%) 6519 ops/sec (±1.18%) 1.04
denormalizeLong Values withCache 5112 ops/sec (±1.27%) 5205 ops/sec (±1.63%) 1.02
denormalizeLong Scalar withCache 7766 ops/sec (±0.25%) 7497 ops/sec (±0.93%) 0.97
denormalizeLong Scalar update withCache 4044 ops/sec (±0.47%) 4117 ops/sec (±0.28%) 1.02
denormalizeLong All withCache 6388 ops/sec (±0.34%) 6088 ops/sec (±0.35%) 0.95
denormalizeLong Query-sorted withCache 6422 ops/sec (±1.46%) 6280 ops/sec (±1.84%) 0.98
denormalizeLongAndShort withEntityCacheOnly 1667 ops/sec (±0.29%) 1692 ops/sec (±0.37%) 1.01
denormalize bidirectional 50 4707 ops/sec (±10.41%) 4553 ops/sec (±10.65%) 0.97
denormalize bidirectional 50 donotcache 41885 ops/sec (±1.59%) 43086 ops/sec (±0.20%) 1.03
getResponse 4473 ops/sec (±3.47%) 4417 ops/sec (±4.06%) 0.99
getResponse (null) 9801823 ops/sec (±1.19%) 9896832 ops/sec (±0.91%) 1.01
getResponse (clear cache) 203 ops/sec (±8.78%) 205 ops/sec (±8.66%) 1.01
getSmallResponse 3547 ops/sec (±1.39%) 3536 ops/sec (±0.25%) 1.00
getSmallInferredResponse 2781 ops/sec (±0.17%) 2910 ops/sec (±0.31%) 1.05
getResponse Collection 4463 ops/sec (±3.49%) 4225 ops/sec (±4.77%) 0.95
get Collection 2864 ops/sec (±0.21%) 2596 ops/sec (±0.78%) 0.91
get Query-sorted 4980 ops/sec (±1.80%) 5026 ops/sec (±1.69%) 1.01
setLong 438 ops/sec (±0.24%) 414 ops/sec (±0.95%) 0.95
setLongWithMerge 250 ops/sec (±0.21%) 246 ops/sec (±0.45%) 0.98
setLongWithSimpleMerge 264 ops/sec (±0.76%) 260 ops/sec (±0.54%) 0.98
setSmallResponse 500x 913 ops/sec (±1.71%) 915 ops/sec (±1.64%) 1.00
setMany 50x one-per-row 152 ops/sec (±0.36%) 146 ops/sec (±0.89%) 0.96
setMany 50 batch 3614 ops/sec (±2.78%) 3507 ops/sec (±2.61%) 0.97
setMany 500x one-per-row 15.7 ops/sec (±0.61%) 15.08 ops/sec (±1.47%) 0.96
setMany 500 batch 1415 ops/sec (±3.10%) 1419 ops/sec (±0.21%) 1.00

This comment was automatically generated by workflow using github-action-benchmark.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: FOLLOW_UP_ONLY on #4097 @ b0f6e54

Architecture holds. The two halves are not redundant: useEnhancedReducer hold keeps React-bound SET_RESPONSE / handleSet / clear tied to a fiber that actually commits (so a discarded tree cannot complete next() and wipe settledByMeta), and NetworkManager.init() re-resolve republishes into the controller that did commit when a shared manager’s held queue died with the discarded fiber. Middleware-still-immediate is the right split. throttle-only noteSettled matches the only path that enters this.fetching. Mutating settled.controller before re-resolve makes a second init() idempotent. Module-level WeakMap is fine — keys are per-FetchingMeta and never shared across manager instances. Strict Mode: heldRef goes [] → null once; remount sees null and does not double-apply. Changeset listing the linked group + use-enhanced-reducer matches .changeset/config.json. Neither half looks over-engineered relative to the failure mode.

FOLLOW_UP

  1. Race suite fixed waits (precommit-race.node-suite.tsx): replace wait(100) / wait(500) with condition-based waits (text contains value 5 / error nope, or calls stable). Fixed sleeps will flake under load; the suite already sets a 20s timeout as a smell.
  2. renderDataHook / ActController.resolve × hold: agreed this is a helper bug, not a reason to unwind the production fix. makeRenderDataClient wraps resolve in synchronous act(), which can race the hold’s mount useEffect (promise microtasks vs passive effects) so the hold never opens and suspense stays pending — especially with sync fixtures. Fix the test helper (flush/open hold reliably, or stop sync-act-wrapping resolve) in a follow-up so useFetch-use.web.tsx and siblings do not timeout. Do not block this PR on that unless CI is red on HEAD.

No CHANGE_THIS_PR.

cursoragent and others added 3 commits September 24, 2026 17:09
Queue actions that reach React before the mount effect and replay them
from that effect. Middleware still runs immediately, and the promise
from dispatch still resolves once the action commits.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
A shared NetworkManager can resolve a fetch into a controller whose
store never commits. On init, publish that result through the controller
that did commit, without calling the endpoint again.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
… race suite

Wait for the endpoint call before releasing the blocker, then for the
expected text and a stable call count. Drops the 20s suite timeout.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor
cursor Bot force-pushed the cursor/precommit-dispatch-hold-6fdf branch from b0f6e54 to 8f0f5a9 Compare September 24, 2026 17:16

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: RE_LGTM on #4097 @ 8f0f5a9

Prior FOLLOW_UP_ONLY @ b0f6e54 is closed:

  1. Race suite now uses condition waits (waitUntil / waitUntilStable) instead of wait(100) / wait(500) — 8f0f5a9a.
  2. renderDataHook / Kick harness is on base via #4099 rebase; not required in this PR.

Production useEnhancedReducer hold + NetworkManager noteSettled/init() are unchanged vs the prior review. Two-layer architecture still holds; no CHANGE_THIS_PR. Merge when required CI (incl. Bugbot) is green on this HEAD.

cursoragent and others added 2 commits September 24, 2026 17:41
React 17 and 18 have no use(), and react-dom 17 has no react-dom/client.
The blocker now suspends by throwing its promise when use() is missing,
and the DOM host falls back to a legacy root on React 17.

Each version asserts what it actually does before the blocker releases:
concurrent roots (18, 19) show no value yet, and legacy roots (17) have
already committed DataProvider, so the race cannot happen there.
Transition cases skip on 17, and use(useFetch()) cases skip before 19.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Use the shared streamingHarness gate instead of a local flag plus
resolver, load legacy react-dom with a typed require instead of a cast,
and keep the case filter boolean.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: RE_LGTM on #4097 @ 6e0e572

Delta vs prior RE_LGTM @ 8f0f5a9 is test-only (255afaac, 6e0e572):

  • Race suite now runs on React 17 and 18 with version-correct assertions: legacy roots expect the value before release (no race); concurrent roots expect it absent before release when a blocker is present (proves the park).
  • Skips match reality (startTransition on 17, use(useFetch()) before 19).
  • DOM host uses ReactDOM.render under LegacyReact; blocker reuses makeGate with throw-for-17.

Production useEnhancedReducer hold + NetworkManager noteSettled/init() are byte-identical to 8f0f5a9. Two-layer architecture still holds. No CHANGE_THIS_PR; no new FOLLOW_UP. Merge when required CI (incl. Bugbot) is green on this HEAD.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 98.08%. Comparing base (a403df8) to head (74600bd).
⚠️ Report is 194 commits behind head on master.

Files with missing lines Patch % Lines
packages/core/src/manager/NetworkManager.ts 94.73% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4097      +/-   ##
==========================================
- Coverage   98.10%   98.08%   -0.03%     
==========================================
  Files         166      166              
  Lines        3167     3186      +19     
  Branches      626      631       +5     
==========================================
+ Hits         3107     3125      +18     
  Misses         18       18              
- Partials       42       43       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cursoragent and others added 2 commits September 24, 2026 18:26
…-commit race suite

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…acy and concurrent roots

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: RE_LGTM on #4097 @ 4c130bb

Delta vs prior RE_LGTM @ 6e0e572 is test-only (b34f277, 4c130bb):

  • Race suite pins fresh-manager call counts by root type (shared || LegacyReact → exactly one call; concurrent fresh managers still assert discard churn).
  • New precommit-hold.tsx covers the hold on legacy and concurrent roots (replay in one mount-effect render; concurrent batches promise-callback dispatches).

Production useEnhancedReducer hold + NetworkManager noteSettled/init() are byte-identical to 6e0e572. Two-layer architecture still holds. No CHANGE_THIS_PR; no new FOLLOW_UP.

Bugbot waive: the open "Outer Suspense hides provider on React 17" finding is a false positive. Same harness @ 6e0e572 had green ci/circleci: unit_tests-^17 (and ^18 / latest / 19.3 / native); ReactDOM collects precommit-race.tsx, so the LegacyReact beforeRelease assertions are already exercised and passing. Do not restructure the outer Suspense for that report.

Merge when required CI (incl. Bugbot re-run or this waive) is green on this HEAD.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot (GPT-6.1 Sol, High) — reviewed 4c130bb86ce252f16ae3831c1d166f96451fdcfb.

One verified P2 regression is attached: under StrictMode, a held dispatch promise can resolve before its state update commits. A head/base probe using the same React 19.2.3 DOM root reproduced resolved: ['0'], final: '2' on head and resolved: ['2'], final: '2' on base.

The existing two concurrent-root precommit-hold tests pass. I also inspected manager settlement/replay, reset handling, cleanup, and idempotent init. I did not rerun the full React/native matrix. I am not repeating the already-waived React 17 Outer Suspense report.

const held = heldRef.current;
if (held === null) return;
heldRef.current = null;
for (const [action, resolve] of held) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot — [P2] Keep held dispatch promises pending through Strict Mode effect replay. These calls create usePromisifiedDispatch's pending promise inside the initial mount effect. In Strict Mode, React immediately replays the earlier usePromisifiedDispatch effect with the original state, which resolves that promise before the queued reducer updates commit. I reproduced this with the actual source, React/ReactDOM 19.2.3, and a StrictMode concurrent root: dispatching {n:1} and {n:2} once during render makes the second dispatch's .then() observe DOM text 0, while the eventual text is 2. Replacing only this hook with the base version makes .then() observe 2. This breaks the hook's commit-completion contract and lets callers read stale state or begin follow-up work too early. Please gate completion on a committed update (rather than a replayed mount effect), and cover this hold path under StrictMode.

cursoragent and others added 3 commits October 5, 2026 05:10
Reverts "Hold dispatches until the hook commits" and its legacy and
concurrent root tests. useEnhancedReducer is back to master. The
fetch result is held by NetworkManager instead, in the next commit.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Replaces "Re-resolve fetches that settled into an uncommitted store".

DataProvider marks the controller it creates with awaitingInit, and
initManager clears it. While it is set, NetworkManager parks a
throttled fetch's resolve arguments on its in-flight record instead of
dispatching into a store React may still discard. init() publishes
parked results from a microtask through controller.resolve, so test
controllers that wrap resolve in act() never re-enter React's commit.

Parked results live on the record, so RESET and clear() drop them.
Mutations resolve immediately, as before. Redux, Vue, and the server
stores never set the flag.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…t race suite

Expo Router's root layout is a React.lazy inside the router's own
Suspense, so DataProvider's first render is a retry that the fetch's
own update can restart. On master the reader stays on the fallback.
Runs on every host and React version the suite already covers.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@ntucker

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): LGTM on the new design at e02b826. One ask before merge: the PR description is out of date.

Why the redesign is better. The fix now lives in one place instead of two. useEnhancedReducer is back to master (8eb5c6b), which also clears the Sol StrictMode finding about held dispatch promises resolving before their update commits, since that hold no longer exists. NetworkManager comes out smaller (+25/−74). What's left is a single rule: while the controller DataProvider created hasn't run initManager(), a throttled fetch keeps its result on its in-flight record instead of dispatching into a store React may throw away.

Things I checked:

  • The reader's thrown promise is the record's promise, so it stays pending until init() publishes and the SET_RESPONSE goes through handleSet. A retried render dedupes onto the same record, so a shared manager calls the endpoint once.
  • The meta.fetchedAt === action.meta.fetchedAt guard parks only the current record. RESET and clear() delete the record, so its parked result goes with it, and the lastReset check still runs before parking.
  • StrictMode's init, cleanup, init sequence queues two microtasks. The first one drains the parked results, the second finds nothing, and cleanupDate is already deleted again by then.
  • Errors park and publish the same way. Mutations (sideEffect) skip parking, as before.
  • Only DataProvider sets awaitingInit, so Redux, Vue, and the Next.js server store keep master behavior. The Next.js client store wraps DataProvider, so it gets the fix.
  • Keeping the flag on the controller instead of a manager-local "initialized" bit is the right call. A host that never calls init() would otherwise park results forever.

Before merge: the description still describes the old two-part design ("useEnhancedReducer queues actions…", "init() re-resolves any result whose controller is not the one that committed", and the precommit-hold.tsx paragraph). None of that is in the diff anymore. Please rewrite it around awaitingInit plus parking, and mention the lazy-layout case from e02b826, so the squash commit and future readers get the real mechanism.

CI: unit_tests-latest failed after 132 ms with exit status 35 before Jest printed anything, so it looks like an infrastructure problem, not a test failure. unit_tests-19.3 and unit_tests-native passed on this SHA. It needs a rerun, and Bugbot is still pending on this tip.

cursoragent and others added 2 commits October 5, 2026 05:24
delete on a property added after construction drops V8 objects to
slow properties. Every DataProvider Controller would stay that way for
the app's lifetime, and its methods sit on the hottest paths.
Also compare the record against the resolution's own fetchedAt.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
The tests now cover the real contract that initManager clears the flag
before managers' init(), instead of a copy of it.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
@cursor cursor Bot changed the title Fix fetches that resolve before DataProvider commits fix(core): Hold fetch results until init() while DataProvider mounts Oct 5, 2026
Co-authored-by: Nathaniel Tucker <me@ntucker.me>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 071b573. Configure here.

Comment thread packages/core/src/manager/NetworkManager.ts Outdated
cursoragent and others added 4 commits October 5, 2026 06:12
handleFetch already knows whether a fetch is in the map. publish uses that flag instead of reading sideEffect again.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Drop the catch that swallowed a throw from the code under test, and type the dispatch mocks as SetResponseAction.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
… init()

The init() microtask published parked results even if cleanup() ran first,
sending them into the unmounted no-op dispatch so the in-flight record never
settled. Skip publishing while cleaned up; the next init() publishes instead.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…d parked fetches

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Reviewed the current commit gate at 1ea7c283. I found no additional confirmed runtime regression. The stale Playground declarations on the preceding commit are regenerated here, and the editor-types check now passes.

Two concrete follow-up test ideas:

  1. Initial hidden Activity → visible. On React 19.2+, mount <Activity mode="hidden"><DataProvider managers={sharedManagers}>...</DataProvider></Activity>, let an immediately resolving fetch finish while hidden, then reveal it. Check that the value appears and the shared endpoint is called exactly once; repeat with a rejection and check that the error boundary receives it. This directly exercises the PR's open question: hidden Activity trees render without mounting Effects, so awaitingInit remains true until reveal. It would establish whether parking is the intended behavior without requiring a broader redesign. React's documented lifecycle: https://react.dev/reference/react/Activity#pre-rendering-content-thats-likely-to-become-visible.

  2. Strict Mode around the pre-commit race. Run the shared-manager, outside-blocker case under <StrictMode>, for both success and failure. Assert one endpoint call, eventual value/error, and no pre-mount warning. A focused manager test can also run initManager(...)(), its cleanup, and another init before flushing the replay microtasks, then verify one publication. The existing tests cover cleanup before replay and a later second init, but not that same-turn init → cleanup → init sequence.

These are coverage suggestions, not additional blocking findings. I inspected the source and CI results; local process execution was unavailable, so I did not rerun the React matrix.

cursoragent and others added 2 commits October 5, 2026 23:43
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
… pre-commit race suite

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
cursoragent and others added 3 commits October 6, 2026 00:22
… and contain a throwing resolve()

init() now scans fetching and posts the Promise job only if a record holds a
parked result; the job scans again so a RESET in the same flush still drops
them. Each parked resolve() is wrapped so a throw becomes that fetch's error,
as on the unparked path, instead of stranding the remaining parked records.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…spatch-hold-6fdf

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…nd the mock Controller

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
cursoragent and others added 2 commits October 6, 2026 00:40
…ommit race

The race suite had its own error boundary and console.error spy. The shared ErrorBoundary and recordConsoleErrors already cover both.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Docs preview: https://docs-site-pr-4097-data-client.vercel.app

Built from 74600bd merged into master (this build).

Park a thunk instead of a tuple, share one settle() for the publish and its
error fallback, and derive throttling from the action in publish(). Tidy
the precommit race suite's case table and act-environment toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k

This branch was successfully deployed

2 active deployments
docs-preview — 74600bdb Deployed Oct 21, 2026 by ntucker via docs deploy #3740
Preview — 74600bdb Deployed Oct 21, 2026 by vercel[bot]
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