From 95b5046ad9656dbe4a3af2b43f5869a314e563b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 16:36:48 +0000 Subject: [PATCH 01/38] fix(use-enhanced-reducer): Hold dispatches until the hook commits 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 --- .../src/__tests__/middleware.tsx | 46 +++++++++++++++++++ .../src/useEnhancedReducer.ts | 26 ++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/packages/use-enhanced-reducer/src/__tests__/middleware.tsx b/packages/use-enhanced-reducer/src/__tests__/middleware.tsx index d79304c2948c..c381efbb9f55 100644 --- a/packages/use-enhanced-reducer/src/__tests__/middleware.tsx +++ b/packages/use-enhanced-reducer/src/__tests__/middleware.tsx @@ -1,3 +1,5 @@ +import { useRef } from 'react'; + import { renderHook, act } from '../../../test'; import { MiddlewareAPI } from '../types'; import useEnhancedReducer from '../useEnhancedReducer'; @@ -209,6 +211,50 @@ describe('createEnhancedReducerHook', () => { expect(callAfter.mock.calls[1][0]).toEqual({ counter: 2 }); }); + test('holds a dispatch from render until commit', () => { + const reducer = jest.fn( + (state: { n: number }, action: { type: string; n?: number }) => + action.n === undefined ? state : { n: action.n }, + ); + let reducerRanDuringRender = false; + const { result } = renderHook(() => { + const [state, dispatch] = useEnhancedReducer(reducer, { n: 0 }, []); + const once = useRef(false); + if (!once.current) { + once.current = true; + const before = reducer.mock.calls.length; + dispatch({ type: 'set', n: 1 }); + if (reducer.mock.calls.length > before) reducerRanDuringRender = true; + } + return state; + }); + expect(reducerRanDuringRender).toBe(false); + expect(result.current).toEqual({ n: 1 }); + }); + + test('runs middleware before a pre-commit dispatch reaches React', () => { + const seen: string[] = []; + const middleware = () => (next: any) => (action: any) => { + seen.push('middleware'); + return next(action); + }; + renderHook(() => { + const [, dispatch] = useEnhancedReducer( + (state: { n: number }) => ({ n: state.n + 1 }), + { n: 0 }, + [middleware], + ); + const once = useRef(false); + if (!once.current) { + once.current = true; + dispatch({ type: 'go' }); + seen.push('returned'); + } + return null; + }); + expect(seen.slice(0, 2)).toEqual(['middleware', 'returned']); + }); + it('warns when dispatching during middleware setup', () => { const consoleErrorSpy = jest .spyOn(console, 'error') diff --git a/packages/use-enhanced-reducer/src/useEnhancedReducer.ts b/packages/use-enhanced-reducer/src/useEnhancedReducer.ts index 49884d6d4c49..247cec4c5357 100644 --- a/packages/use-enhanced-reducer/src/useEnhancedReducer.ts +++ b/packages/use-enhanced-reducer/src/useEnhancedReducer.ts @@ -41,7 +41,31 @@ export default function useEnhancedReducer>( const getState = useCallback(() => stateRef.current, []); - const dispatchWithPromise = usePromisifiedDispatch(realDispatch, state); + const promisifiedDispatch = usePromisifiedDispatch(realDispatch, state); + // Queue actions that reach React before this hook commits. The mount effect + // replays them, and that effect runs only once this fiber has committed, so + // the update lands on a mounted reducer. Middleware still runs immediately. + const heldRef = useRef< + null | [ReducerAction, (value: Promise | void) => void][] + >([]); + const dispatchWithPromise = useCallback( + (action: ReducerAction) => { + const held = heldRef.current; + if (held === null) return promisifiedDispatch(action); + return new Promise(resolve => { + held.push([action, resolve]); + }); + }, + [promisifiedDispatch], + ); + useEffect(() => { + const held = heldRef.current; + if (held === null) return; + heldRef.current = null; + for (const [action, resolve] of held) { + resolve(promisifiedDispatch(action)); + } + }, [promisifiedDispatch]); const outerDispatchRef = useRef>(unsetDispatch); // protected from dispatches after unmount From 106a3795f3942b2282737c7963dc2a13c397d252 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 16:36:48 +0000 Subject: [PATCH 02/38] fix(core): Re-resolve fetches that settled into an uncommitted store 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 --- .changeset/precommit-fetch.md | 18 ++ packages/core/src/manager/NetworkManager.ts | 75 +++++- .../src/manager/__tests__/networkManager.ts | 91 +++++++ .../__tests__/precommit-race.native.tsx | 29 +++ .../__tests__/precommit-race.node-suite.tsx | 243 ++++++++++++++++++ .../components/__tests__/precommit-race.tsx | 31 +++ 6 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 .changeset/precommit-fetch.md create mode 100644 packages/react/src/components/__tests__/precommit-race.native.tsx create mode 100644 packages/react/src/components/__tests__/precommit-race.node-suite.tsx create mode 100644 packages/react/src/components/__tests__/precommit-race.tsx diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md new file mode 100644 index 000000000000..0f90779b6886 --- /dev/null +++ b/.changeset/precommit-fetch.md @@ -0,0 +1,18 @@ +--- +'@data-client/core': patch +'@data-client/endpoint': patch +'@data-client/graphql': patch +'@data-client/img': patch +'@data-client/normalizr': patch +'@data-client/react': patch +'@data-client/rest': patch +'@data-client/test': patch +'@data-client/vue': patch +'@data-client/use-enhanced-reducer': patch +--- + +Fix Suspense staying on the fallback when a fetch resolves before the store commits + +A [useSuspense](/docs/api/useSuspense) or `use(useFetch())` read that finishes while [DataProvider](/docs/api/DataProvider) is still rendering now shows its result once the provider commits, instead of leaving the fallback up. The endpoint is not called again. A fetch that fails is included. + +[useEnhancedReducer](https://www.npmjs.com/package/@data-client/use-enhanced-reducer) applies actions that arrive before the hook has committed, in order, from its mount effect. `dispatch` still returns a promise that resolves when that action commits. diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index d378a0547082..2d1cbbea4f62 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -24,6 +24,38 @@ export interface FetchingMeta { fetchedAt: number; } +/** Result of a throttled fetch, and the controller it was resolved into. */ +interface SettledFetch { + controller: Controller; + endpoint: FetchAction['endpoint']; + args: FetchAction['args']; + response: unknown; + fetchedAt: number; + error: boolean; +} + +const settledByMeta = new WeakMap(); + +function noteSettled( + fetching: Map, + controller: Controller, + action: FetchAction, + fetchedAt: number, + response: unknown, + error: boolean, +) { + const meta = fetching.get(action.key); + if (!meta || meta.fetchedAt !== fetchedAt) return; + settledByMeta.set(meta, { + controller, + endpoint: action.endpoint, + args: action.args, + response, + fetchedAt, + error, + }); +} + /** Handles all async network dispatches * * Dedupes concurrent requests by keeping track of all fetches in flight @@ -105,6 +137,27 @@ export default class NetworkManager implements Manager { /** On mount */ init() { delete this.cleanupDate; + // A fetch can resolve into a store that never commits. Replay that result + // through the controller that just committed, without calling the endpoint. + for (const meta of this.fetching.values()) { + const settled = settledByMeta.get(meta); + if (!settled || settled.controller === this.controller) continue; + settled.controller = this.controller; + if (settled.error) { + this.controller.resolve(settled.endpoint, { + args: settled.args, + response: settled.response as Error, + fetchedAt: settled.fetchedAt, + error: true, + }); + } else { + this.controller.resolve(settled.endpoint, { + args: settled.args, + response: settled.response, + fetchedAt: settled.fetchedAt, + }); + } + } } /** Ensures all promises are completed by rejecting remaining. */ @@ -137,7 +190,9 @@ export default class NetworkManager implements Manager { /** Clear promise state for a given key */ protected clear(key: string) { if (this.fetching.has(key)) { - (this.fetching.get(key) as FetchingMeta).promise.catch(() => {}); + const meta = this.fetching.get(key) as FetchingMeta; + meta.promise.catch(() => {}); + settledByMeta.delete(meta); this.fetching.delete(key); } } @@ -193,6 +248,15 @@ export default class NetworkManager implements Manager { // don't update state with promises started before last clear if (fetchedAt >= lastReset) { + if (throttle) + noteSettled( + this.fetching, + this.controller, + action, + fetchedAt, + response, + false, + ); this.controller.resolve(action.endpoint, { args: action.args, response, @@ -205,6 +269,15 @@ export default class NetworkManager implements Manager { const lastReset = this.getLastReset(); // don't update state with promises started before last clear if (fetchedAt >= lastReset) { + if (throttle) + noteSettled( + this.fetching, + this.controller, + action, + fetchedAt, + error, + true, + ); this.controller.resolve(action.endpoint, { args: action.args, response: error, diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 6e6ace650666..55b6b3881eab 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -397,3 +397,94 @@ describe('NetworkManager', () => { }); }); }); + +describe('NetworkManager re-resolves a fetch whose store never committed', () => { + function bind(dispatch: jest.Mock) { + return new Controller({ dispatch, getState: () => initialState }); + } + + async function start(nm: NetworkManager, endpoint: any, dispatch: jest.Mock) { + const controller = bind(dispatch); + const action = createFetch(endpoint, { args: [] }); + void Promise.resolve(action.meta.promise).catch(() => {}); + const pending = nm.middleware(controller)(jest.fn(() => Promise.resolve()))( + action, + ); + await Promise.resolve(pending).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + return { controller, action }; + } + + it('does not resolve again when the same controller commits', async () => { + let calls = 0; + const endpoint = new Endpoint( + () => { + calls += 1; + return Promise.resolve(5); + }, + { name: 'settledSame' }, + ); + const dispatch = jest.fn(() => Promise.resolve()); + const nm = new NetworkManager(); + await start(nm, endpoint, dispatch); + expect(calls).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + nm.init(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(calls).toBe(1); + nm.cleanup(); + }); + + it('re-resolves a response into the controller that commits', async () => { + let calls = 0; + const endpoint = new Endpoint( + () => { + calls += 1; + return Promise.resolve(5); + }, + { name: 'settledValue' }, + ); + const first = jest.fn((_action: any) => Promise.resolve()); + const second = jest.fn((_action: any) => Promise.resolve()); + const nm = new NetworkManager(); + await start(nm, endpoint, first); + nm.middleware(bind(second)); + nm.init(); + expect(calls).toBe(1); + expect(second).toHaveBeenCalledTimes(1); + expect(second.mock.calls[0][0]).toMatchObject({ + type: SET_RESPONSE, + response: 5, + error: false, + }); + nm.init(); + expect(second).toHaveBeenCalledTimes(1); + nm.cleanup(); + }); + + it('re-resolves an error into the controller that commits', async () => { + let calls = 0; + const failure = new Error('nope'); + const endpoint = new Endpoint( + () => { + calls += 1; + return Promise.reject(failure); + }, + { name: 'settledError' }, + ); + const first = jest.fn((_action: any) => Promise.resolve()); + const second = jest.fn((_action: any) => Promise.resolve()); + const nm = new NetworkManager(); + await start(nm, endpoint, first); + nm.middleware(bind(second)); + nm.init(); + expect(calls).toBe(1); + expect(second).toHaveBeenCalledTimes(1); + expect(second.mock.calls[0][0]).toMatchObject({ + type: SET_RESPONSE, + response: failure, + error: true, + }); + nm.cleanup(); + }); +}); diff --git a/packages/react/src/components/__tests__/precommit-race.native.tsx b/packages/react/src/components/__tests__/precommit-race.native.tsx new file mode 100644 index 000000000000..647d62431a70 --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.native.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import { Text } from 'react-native'; +import TestRenderer from 'react-test-renderer'; + +import { + registerPrecommitRaceTests, + type RaceHost, +} from './precommit-race.node-suite'; + +const host: RaceHost = { + Text: ({ children }: { children?: ReactNode }) => {children}, + createRenderer() { + let renderer: TestRenderer.ReactTestRenderer | undefined; + return { + render(node) { + if (!renderer) renderer = TestRenderer.create(node); + else renderer.update(node); + }, + read() { + return JSON.stringify(renderer?.toJSON() ?? ''); + }, + unmount() { + renderer?.unmount(); + }, + }; + }, +}; + +registerPrecommitRaceTests(host); diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx new file mode 100644 index 000000000000..20ad2aa19838 --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -0,0 +1,243 @@ +/** + * Race: a fetch resolves while DataProvider has not committed, because a + * sibling `use()` is still pending. + * + * The filename contains `.node-` so Jest does not collect this module. + * ReactDOM skips paths containing `.node`, and the Node project only + * collects files ending in `.node.ts`. The host entrypoints call + * `registerPrecommitRaceTests`. + */ +import { Endpoint } from '@data-client/endpoint'; +import { use, startTransition, Suspense, Component } from 'react'; +import type { ReactElement, ReactNode } from 'react'; + +import { useFetch, useSuspense } from '../../hooks'; +import DataProvider from '../DataProvider'; +import { getDefaultManagers } from '../getDefaultManagers'; + +export interface RaceRenderer { + render(node: ReactElement): void; + read(): string; + unmount(): void; +} + +export interface RaceHost { + createRenderer(): RaceRenderer; + Text: (props: { children?: ReactNode }) => ReactElement; +} + +const CALL_CAP = 30; + +function wait(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +function makeEndpoint(settle: 'value' | 'error') { + let calls = 0; + const endpoint = new Endpoint( + () => { + calls += 1; + if (calls > CALL_CAP) return new Promise(() => {}); + if (settle === 'error') return Promise.reject(new Error('nope')); + return Promise.resolve(5); + }, + { dataExpiryLength: Infinity, name: 'precommit-race' }, + ); + return { endpoint, getCalls: () => calls }; +} + +class ErrorBox extends Component< + { children?: ReactNode; Text: RaceHost['Text'] }, + { message: string } +> { + state = { message: '' }; + + static getDerivedStateFromError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { message }; + } + + render() { + const { Text } = this.props; + if (this.state.message) { + return {`error ${this.state.message}`}; + } + return this.props.children; + } +} + +function SuspenseReader({ + endpoint, + Text, +}: { + endpoint: ReturnType['endpoint']; + Text: RaceHost['Text']; +}) { + const value = useSuspense(endpoint); + return {`value ${value}`}; +} + +function FetchReader({ + endpoint, + Text, +}: { + endpoint: ReturnType['endpoint']; + Text: RaceHost['Text']; +}) { + const value = use(useFetch(endpoint)); + return {`value ${value}`}; +} + +function Blocker({ promise }: { promise: Promise }) { + use(promise); + return null; +} + +type Where = 'outside' | 'inside' | 'none'; +type ReaderKind = 'suspense' | 'fetch'; + +async function runRace( + host: RaceHost, + { + shared, + where, + transition, + reader, + settle, + }: { + shared: boolean; + where: Where; + transition: boolean; + reader: ReaderKind; + settle: 'value' | 'error'; + }, +) { + const errors: unknown[][] = []; + const spy = jest.spyOn(console, 'error').mockImplementation((...args) => { + errors.push(args); + }); + const { endpoint, getCalls } = makeEndpoint(settle); + let release: (value?: unknown) => void = () => {}; + const pending = new Promise(resolve => { + release = resolve; + }); + const managers = shared ? getDefaultManagers() : undefined; + const Text = host.Text; + const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; + const tree = ( + <> + + + fallback}> + + + {where === 'inside' ? + + : null} + + + {where === 'outside' ? + + : null} + + ); + const renderer = host.createRenderer(); + try { + if (transition) startTransition(() => renderer.render(tree)); + else renderer.render(tree); + await wait(100); + release(true); + await wait(500); + const warned = errors.some(args => + args.join(' ').includes("hasn't mounted yet"), + ); + return { text: renderer.read(), calls: getCalls(), warned }; + } finally { + renderer.unmount(); + spy.mockRestore(); + } +} + +function expectResolved( + result: { text: string; calls: number; warned: boolean }, + { shared, settle }: { shared: boolean; settle: 'value' | 'error' }, +) { + const expected = settle === 'error' ? 'error nope' : 'value 5'; + expect(result.text).toContain(expected); + if (shared) { + expect(result.calls).toBe(1); + } else { + expect(result.calls).toBeGreaterThan(0); + expect(result.calls).toBeLessThan(10); + } + expect(result.warned).toBe(false); +} + +export function registerPrecommitRaceTests(host: RaceHost) { + describe('pre-commit fetch while a sibling use() is pending', () => { + let prevAct: boolean | undefined; + + beforeAll(() => { + prevAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT; + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = false; + }); + + afterAll(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = prevAct; + }); + + const cases: { + shared: boolean; + where: Where; + transition: boolean; + reader: ReaderKind; + }[] = [ + { + shared: true, + where: 'outside', + transition: false, + reader: 'suspense', + }, + { shared: true, where: 'outside', transition: true, reader: 'suspense' }, + { + shared: false, + where: 'outside', + transition: false, + reader: 'suspense', + }, + { shared: true, where: 'inside', transition: false, reader: 'suspense' }, + { shared: true, where: 'none', transition: false, reader: 'suspense' }, + { shared: true, where: 'outside', transition: false, reader: 'fetch' }, + { shared: true, where: 'outside', transition: true, reader: 'fetch' }, + { shared: false, where: 'outside', transition: false, reader: 'fetch' }, + ]; + + jest.setTimeout(20000); + + test.each(cases)( + 'shared=$shared where=$where transition=$transition reader=$reader', + async spec => { + const result = await runRace(host, { ...spec, settle: 'value' }); + expectResolved(result, { shared: spec.shared, settle: 'value' }); + }, + ); + + it('re-resolves a rejected fetch without calling the endpoint again', async () => { + const result = await runRace(host, { + shared: true, + where: 'outside', + transition: false, + reader: 'suspense', + settle: 'error', + }); + expectResolved(result, { shared: true, settle: 'error' }); + }); + }); +} diff --git a/packages/react/src/components/__tests__/precommit-race.tsx b/packages/react/src/components/__tests__/precommit-race.tsx new file mode 100644 index 000000000000..7a2291067679 --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import type { Root } from 'react-dom/client'; + +import { + registerPrecommitRaceTests, + type RaceHost, +} from './precommit-race.node-suite'; + +const host: RaceHost = { + Text: ({ children }: { children?: ReactNode }) => {children}, + createRenderer() { + const container = document.createElement('div'); + document.body.appendChild(container); + const root: Root = createRoot(container); + return { + render(node) { + root.render(node); + }, + read() { + return container.textContent ?? ''; + }, + unmount() { + root.unmount(); + container.remove(); + }, + }; + }, +}; + +registerPrecommitRaceTests(host); From 8f0f5a9a511f0dfc50c80a4e3e91f477227399b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 17:16:22 +0000 Subject: [PATCH 03/38] test(react): Wait on conditions instead of fixed sleeps in pre-commit 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 --- .../__tests__/precommit-race.node-suite.tsx | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 20ad2aa19838..3a7828f3b43d 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -27,13 +27,33 @@ export interface RaceHost { } const CALL_CAP = 30; +const POLL_MS = 10; +// three waits must fit inside Jest's default 5s test timeout +const WAIT_TIMEOUT_MS = 1500; -function wait(ms: number) { +function tick() { return new Promise(resolve => { - setTimeout(resolve, ms); + setTimeout(resolve, POLL_MS); }); } +async function waitUntil(condition: () => boolean) { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + while (!condition() && Date.now() < deadline) await tick(); +} + +async function waitUntilStable(read: () => number, polls = 5) { + const deadline = Date.now() + WAIT_TIMEOUT_MS; + let last = read(); + let unchanged = 0; + while (unchanged < polls && Date.now() < deadline) { + await tick(); + const next = read(); + unchanged = next === last ? unchanged + 1 : 0; + last = next; + } +} + function makeEndpoint(settle: 'value' | 'error') { let calls = 0; const endpoint = new Endpoint( @@ -143,13 +163,17 @@ async function runRace( : null} ); + const expected = settle === 'error' ? 'error nope' : 'value 5'; const renderer = host.createRenderer(); try { if (transition) startTransition(() => renderer.render(tree)); else renderer.render(tree); - await wait(100); + // the extra tick lets the fetch settle while the render is still parked + await waitUntil(() => getCalls() > 0); + await tick(); release(true); - await wait(500); + await waitUntil(() => renderer.read().includes(expected)); + await waitUntilStable(getCalls); const warned = errors.some(args => args.join(' ').includes("hasn't mounted yet"), ); @@ -219,8 +243,6 @@ export function registerPrecommitRaceTests(host: RaceHost) { { shared: false, where: 'outside', transition: false, reader: 'fetch' }, ]; - jest.setTimeout(20000); - test.each(cases)( 'shared=$shared where=$where transition=$transition reader=$reader', async spec => { From 255afaacecbc48e2f2332455dddbbc0bc9f88ba6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 17:41:37 +0000 Subject: [PATCH 04/38] test(react): Run pre-commit race suite on React 17 and 18 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 --- .../__tests__/precommit-race.node-suite.tsx | 77 +++++++++++++------ .../components/__tests__/precommit-race.tsx | 26 ++++++- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 3a7828f3b43d..768594d62ce4 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -6,6 +6,9 @@ * ReactDOM skips paths containing `.node`, and the Node project only * collects files ending in `.node.ts`. The host entrypoints call * `registerPrecommitRaceTests`. + * + * React 17 only has legacy roots, which commit DataProvider while a sibling + * is suspended, so the race cannot happen there. `use()` exists from 19. */ import { Endpoint } from '@data-client/endpoint'; import { use, startTransition, Suspense, Component } from 'react'; @@ -14,6 +17,7 @@ import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; import DataProvider from '../DataProvider'; import { getDefaultManagers } from '../getDefaultManagers'; +import { LegacyReact } from '../LegacyReact'; export interface RaceRenderer { render(node: ReactElement): void; @@ -110,8 +114,14 @@ function FetchReader({ return {`value ${value}`}; } -function Blocker({ promise }: { promise: Promise }) { - use(promise); +interface Gate { + promise: Promise; + open: boolean; +} + +function Blocker({ gate }: { gate: Gate }) { + if (use) use(gate.promise); + else if (!gate.open) throw gate.promise; return null; } @@ -140,9 +150,12 @@ async function runRace( }); const { endpoint, getCalls } = makeEndpoint(settle); let release: (value?: unknown) => void = () => {}; - const pending = new Promise(resolve => { - release = resolve; - }); + const gate: Gate = { + promise: new Promise(resolve => { + release = resolve; + }), + open: false, + }; const managers = shared ? getDefaultManagers() : undefined; const Text = host.Text; const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; @@ -154,30 +167,37 @@ async function runRace( {where === 'inside' ? - + : null} {where === 'outside' ? - + : null} ); const expected = settle === 'error' ? 'error nope' : 'value 5'; const renderer = host.createRenderer(); try { - if (transition) startTransition(() => renderer.render(tree)); - else renderer.render(tree); + // legacy roots throw when a component suspends outside every boundary + const root = + LegacyReact ? + outer}>{tree} + : tree; + if (transition) startTransition(() => renderer.render(root)); + else renderer.render(root); // the extra tick lets the fetch settle while the render is still parked await waitUntil(() => getCalls() > 0); await tick(); + const beforeRelease = renderer.read(); + gate.open = true; release(true); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); const warned = errors.some(args => args.join(' ').includes("hasn't mounted yet"), ); - return { text: renderer.read(), calls: getCalls(), warned }; + return { beforeRelease, text: renderer.read(), calls: getCalls(), warned }; } finally { renderer.unmount(); spy.mockRestore(); @@ -185,10 +205,19 @@ async function runRace( } function expectResolved( - result: { text: string; calls: number; warned: boolean }, - { shared, settle }: { shared: boolean; settle: 'value' | 'error' }, + result: Awaited>, + { + shared, + where, + settle, + }: { shared: boolean; where: Where; settle: 'value' | 'error' }, ) { const expected = settle === 'error' ? 'error nope' : 'value 5'; + if (LegacyReact) { + expect(result.beforeRelease).toContain(expected); + } else if (where !== 'none') { + expect(result.beforeRelease).not.toContain(expected); + } expect(result.text).toContain(expected); if (shared) { expect(result.calls).toBe(1); @@ -243,23 +272,27 @@ export function registerPrecommitRaceTests(host: RaceHost) { { shared: false, where: 'outside', transition: false, reader: 'fetch' }, ]; - test.each(cases)( - 'shared=$shared where=$where transition=$transition reader=$reader', - async spec => { - const result = await runRace(host, { ...spec, settle: 'value' }); - expectResolved(result, { shared: spec.shared, settle: 'value' }); - }, - ); + for (const spec of cases) { + const supported = + (!spec.transition || !LegacyReact) && (spec.reader !== 'fetch' || use); + (supported ? test : test.skip)( + `shared=${spec.shared} where=${spec.where} transition=${spec.transition} reader=${spec.reader}`, + async () => { + const result = await runRace(host, { ...spec, settle: 'value' }); + expectResolved(result, { ...spec, settle: 'value' }); + }, + ); + } it('re-resolves a rejected fetch without calling the endpoint again', async () => { - const result = await runRace(host, { + const spec = { shared: true, where: 'outside', transition: false, reader: 'suspense', settle: 'error', - }); - expectResolved(result, { shared: true, settle: 'error' }); + } as const; + expectResolved(await runRace(host, spec), spec); }); }); } diff --git a/packages/react/src/components/__tests__/precommit-race.tsx b/packages/react/src/components/__tests__/precommit-race.tsx index 7a2291067679..1bd6e83c65d0 100644 --- a/packages/react/src/components/__tests__/precommit-race.tsx +++ b/packages/react/src/components/__tests__/precommit-race.tsx @@ -1,18 +1,36 @@ -import type { ReactNode } from 'react'; -import { createRoot } from 'react-dom/client'; -import type { Root } from 'react-dom/client'; +import type { ReactElement, ReactNode } from 'react'; +import * as ReactDOM from 'react-dom'; +import { LegacyReact } from '../LegacyReact'; import { registerPrecommitRaceTests, type RaceHost, + type RaceRenderer, } from './precommit-race.node-suite'; +// react-dom 17 has no `react-dom/client`, and React 19 types drop `render` +const legacyDOM = ReactDOM as unknown as { + render(node: ReactElement, container: Element): void; + unmountComponentAtNode(container: Element): boolean; +}; + +function createRoot(container: HTMLElement): Omit { + if (LegacyReact) { + return { + render: node => legacyDOM.render(node, container), + unmount: () => legacyDOM.unmountComponentAtNode(container), + }; + } + const client: typeof import('react-dom/client') = require('react-dom/client'); + return client.createRoot(container); +} + const host: RaceHost = { Text: ({ children }: { children?: ReactNode }) => {children}, createRenderer() { const container = document.createElement('div'); document.body.appendChild(container); - const root: Root = createRoot(container); + const root = createRoot(container); return { render(node) { root.render(node); From 6e0e572de5dfdc137001284c3942205058d8e8fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 17:45:01 +0000 Subject: [PATCH 05/38] test(react): Reuse makeGate in pre-commit race suite 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 --- .../__tests__/precommit-race.node-suite.tsx | 39 +++++++------------ .../components/__tests__/precommit-race.tsx | 16 ++++---- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 768594d62ce4..d7d53a96299e 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -11,6 +11,7 @@ * is suspended, so the race cannot happen there. `use()` exists from 19. */ import { Endpoint } from '@data-client/endpoint'; +import { makeGate, type GatePromise } from '__tests__/streamingHarness'; import { use, startTransition, Suspense, Component } from 'react'; import type { ReactElement, ReactNode } from 'react'; @@ -114,14 +115,9 @@ function FetchReader({ return {`value ${value}`}; } -interface Gate { - promise: Promise; - open: boolean; -} - -function Blocker({ gate }: { gate: Gate }) { - if (use) use(gate.promise); - else if (!gate.open) throw gate.promise; +function Blocker({ gate }: { gate: GatePromise }) { + if (use) use(gate); + else if (!gate.done) throw gate; return null; } @@ -149,13 +145,7 @@ async function runRace( errors.push(args); }); const { endpoint, getCalls } = makeEndpoint(settle); - let release: (value?: unknown) => void = () => {}; - const gate: Gate = { - promise: new Promise(resolve => { - release = resolve; - }), - open: false, - }; + const gate = makeGate(); const managers = shared ? getDefaultManagers() : undefined; const Text = host.Text; const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; @@ -176,22 +166,21 @@ async function runRace( : null} ); + // legacy roots throw when a component suspends outside every boundary + const element = + LegacyReact ? + outer}>{tree} + : tree; const expected = settle === 'error' ? 'error nope' : 'value 5'; const renderer = host.createRenderer(); try { - // legacy roots throw when a component suspends outside every boundary - const root = - LegacyReact ? - outer}>{tree} - : tree; - if (transition) startTransition(() => renderer.render(root)); - else renderer.render(root); + if (transition) startTransition(() => renderer.render(element)); + else renderer.render(element); // the extra tick lets the fetch settle while the render is still parked await waitUntil(() => getCalls() > 0); await tick(); const beforeRelease = renderer.read(); - gate.open = true; - release(true); + gate.release(); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); const warned = errors.some(args => @@ -274,7 +263,7 @@ export function registerPrecommitRaceTests(host: RaceHost) { for (const spec of cases) { const supported = - (!spec.transition || !LegacyReact) && (spec.reader !== 'fetch' || use); + !(spec.transition && LegacyReact) && !(spec.reader === 'fetch' && !use); (supported ? test : test.skip)( `shared=${spec.shared} where=${spec.where} transition=${spec.transition} reader=${spec.reader}`, async () => { diff --git a/packages/react/src/components/__tests__/precommit-race.tsx b/packages/react/src/components/__tests__/precommit-race.tsx index 1bd6e83c65d0..6415ba4a8094 100644 --- a/packages/react/src/components/__tests__/precommit-race.tsx +++ b/packages/react/src/components/__tests__/precommit-race.tsx @@ -1,5 +1,4 @@ import type { ReactElement, ReactNode } from 'react'; -import * as ReactDOM from 'react-dom'; import { LegacyReact } from '../LegacyReact'; import { @@ -8,17 +7,16 @@ import { type RaceRenderer, } from './precommit-race.node-suite'; -// react-dom 17 has no `react-dom/client`, and React 19 types drop `render` -const legacyDOM = ReactDOM as unknown as { - render(node: ReactElement, container: Element): void; - unmountComponentAtNode(container: Element): boolean; -}; - function createRoot(container: HTMLElement): Omit { if (LegacyReact) { + // react-dom 17 has no `react-dom/client`, and React 19 types drop `render` + const dom: { + render(node: ReactElement, container: Element): void; + unmountComponentAtNode(container: Element): boolean; + } = require('react-dom'); return { - render: node => legacyDOM.render(node, container), - unmount: () => legacyDOM.unmountComponentAtNode(container), + render: node => dom.render(node, container), + unmount: () => dom.unmountComponentAtNode(container), }; } const client: typeof import('react-dom/client') = require('react-dom/client'); From b34f2771fba52de0d1a66c0a0de3b78079ff4e0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 18:26:23 +0000 Subject: [PATCH 06/38] test(react): Pin fresh-manager endpoint calls to the root type in pre-commit race suite Co-authored-by: Nathaniel Tucker --- .../src/components/__tests__/precommit-race.node-suite.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index d7d53a96299e..2039fa07733a 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -208,10 +208,11 @@ function expectResolved( expect(result.beforeRelease).not.toContain(expected); } expect(result.text).toContain(expected); - if (shared) { + if (shared || LegacyReact) { expect(result.calls).toBe(1); } else { - expect(result.calls).toBeGreaterThan(0); + // every discarded provider brought its own NetworkManager + expect(result.calls).toBeGreaterThan(1); expect(result.calls).toBeLessThan(10); } expect(result.warned).toBe(false); From 4c130bb86ce252f16ae3831c1d166f96451fdcfb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 18:26:23 +0000 Subject: [PATCH 07/38] test(use-enhanced-reducer): Cover the pre-commit dispatch hold on legacy and concurrent roots Co-authored-by: Nathaniel Tucker --- .../src/__tests__/precommit-hold.tsx | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx diff --git a/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx b/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx new file mode 100644 index 000000000000..103dcfa9312c --- /dev/null +++ b/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, version } from 'react'; +import type { ReactElement } from 'react'; + +import useEnhancedReducer from '../useEnhancedReducer'; + +type RootKind = 'legacy' | 'concurrent'; + +const reactMajor = Number(version.split('.')[0]); + +function mount(kind: RootKind, node: ReactElement, container: Element) { + if (kind === 'legacy') { + // React 19 types drop `render` + const dom: { + render(node: ReactElement, container: Element): void; + unmountComponentAtNode(container: Element): boolean; + } = require('react-dom'); + dom.render(node, container); + return () => { + dom.unmountComponentAtNode(container); + }; + } + const client: typeof import('react-dom/client') = require('react-dom/client'); + const root = client.createRoot(container); + root.render(node); + return () => root.unmount(); +} + +function tick() { + return new Promise(resolve => setTimeout(resolve, 20)); +} + +interface Action { + n: number; +} + +let unmount = () => {}; + +function setup(kind: RootKind) { + const container = document.createElement('div'); + const read = () => container.textContent; + const counts = { renders: 0 }; + const resolved: (string | null)[] = []; + let dispatch: (action: Action) => Promise = () => Promise.resolve(); + function Counter() { + counts.renders += 1; + const [state, d] = useEnhancedReducer( + (_: Action, action: Action) => action, + { n: 0 }, + [], + ); + const once = useRef(false); + if (!once.current) { + once.current = true; + d({ n: 1 }); + d({ n: 2 }).then(() => resolved.push(read())); + } + useEffect(() => { + dispatch = d; + }, [d]); + return {state.n}; + } + unmount = mount(kind, , container); + return { + counts, + resolved, + read, + dispatch: (action: Action) => dispatch(action), + }; +} + +const kinds = (['legacy', 'concurrent'] as const).filter(kind => + kind === 'legacy' ? reactMajor <= 18 : reactMajor >= 18, +); + +describe.each(kinds)('dispatch before commit on a %s root', kind => { + const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; + let prevAct: boolean | undefined; + beforeAll(() => { + prevAct = g.IS_REACT_ACT_ENVIRONMENT; + g.IS_REACT_ACT_ENVIRONMENT = false; + }); + afterAll(() => { + g.IS_REACT_ACT_ENVIRONMENT = prevAct; + }); + beforeEach(() => { + // 17 warns about updates outside act(); 18 warns that ReactDOM.render is deprecated + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + unmount(); + jest.restoreAllMocks(); + }); + + it('replays held actions in one render from the mount effect', async () => { + const { counts, resolved, read } = setup(kind); + if (kind === 'legacy') { + // committed synchronously, mount effect still pending + expect(counts.renders).toBe(1); + expect(read()).toBe('0'); + } else { + expect(counts.renders).toBe(0); + } + await tick(); + expect(counts.renders).toBe(2); + expect(read()).toBe('2'); + expect(resolved).toEqual(['2']); + }); + + it('batches two dispatches from a promise callback only on concurrent roots', async () => { + const { counts, dispatch, read } = setup(kind); + await tick(); + const before = counts.renders; + await Promise.resolve().then(() => { + dispatch({ n: 3 }); + dispatch({ n: 4 }); + }); + await tick(); + if (kind === 'legacy') expect(counts.renders - before).toBeGreaterThan(1); + else expect(counts.renders - before).toBe(1); + expect(read()).toBe('4'); + }); +}); From 8eb5c6b86223ca901adeb2eb87409e571c952011 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 05:10:28 +0000 Subject: [PATCH 08/38] revert(use-enhanced-reducer): Drop the pre-commit dispatch hold 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 --- .../src/__tests__/middleware.tsx | 46 ------- .../src/__tests__/precommit-hold.tsx | 122 ------------------ .../src/useEnhancedReducer.ts | 26 +--- 3 files changed, 1 insertion(+), 193 deletions(-) delete mode 100644 packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx diff --git a/packages/use-enhanced-reducer/src/__tests__/middleware.tsx b/packages/use-enhanced-reducer/src/__tests__/middleware.tsx index c381efbb9f55..d79304c2948c 100644 --- a/packages/use-enhanced-reducer/src/__tests__/middleware.tsx +++ b/packages/use-enhanced-reducer/src/__tests__/middleware.tsx @@ -1,5 +1,3 @@ -import { useRef } from 'react'; - import { renderHook, act } from '../../../test'; import { MiddlewareAPI } from '../types'; import useEnhancedReducer from '../useEnhancedReducer'; @@ -211,50 +209,6 @@ describe('createEnhancedReducerHook', () => { expect(callAfter.mock.calls[1][0]).toEqual({ counter: 2 }); }); - test('holds a dispatch from render until commit', () => { - const reducer = jest.fn( - (state: { n: number }, action: { type: string; n?: number }) => - action.n === undefined ? state : { n: action.n }, - ); - let reducerRanDuringRender = false; - const { result } = renderHook(() => { - const [state, dispatch] = useEnhancedReducer(reducer, { n: 0 }, []); - const once = useRef(false); - if (!once.current) { - once.current = true; - const before = reducer.mock.calls.length; - dispatch({ type: 'set', n: 1 }); - if (reducer.mock.calls.length > before) reducerRanDuringRender = true; - } - return state; - }); - expect(reducerRanDuringRender).toBe(false); - expect(result.current).toEqual({ n: 1 }); - }); - - test('runs middleware before a pre-commit dispatch reaches React', () => { - const seen: string[] = []; - const middleware = () => (next: any) => (action: any) => { - seen.push('middleware'); - return next(action); - }; - renderHook(() => { - const [, dispatch] = useEnhancedReducer( - (state: { n: number }) => ({ n: state.n + 1 }), - { n: 0 }, - [middleware], - ); - const once = useRef(false); - if (!once.current) { - once.current = true; - dispatch({ type: 'go' }); - seen.push('returned'); - } - return null; - }); - expect(seen.slice(0, 2)).toEqual(['middleware', 'returned']); - }); - it('warns when dispatching during middleware setup', () => { const consoleErrorSpy = jest .spyOn(console, 'error') diff --git a/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx b/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx deleted file mode 100644 index 103dcfa9312c..000000000000 --- a/packages/use-enhanced-reducer/src/__tests__/precommit-hold.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useEffect, useRef, version } from 'react'; -import type { ReactElement } from 'react'; - -import useEnhancedReducer from '../useEnhancedReducer'; - -type RootKind = 'legacy' | 'concurrent'; - -const reactMajor = Number(version.split('.')[0]); - -function mount(kind: RootKind, node: ReactElement, container: Element) { - if (kind === 'legacy') { - // React 19 types drop `render` - const dom: { - render(node: ReactElement, container: Element): void; - unmountComponentAtNode(container: Element): boolean; - } = require('react-dom'); - dom.render(node, container); - return () => { - dom.unmountComponentAtNode(container); - }; - } - const client: typeof import('react-dom/client') = require('react-dom/client'); - const root = client.createRoot(container); - root.render(node); - return () => root.unmount(); -} - -function tick() { - return new Promise(resolve => setTimeout(resolve, 20)); -} - -interface Action { - n: number; -} - -let unmount = () => {}; - -function setup(kind: RootKind) { - const container = document.createElement('div'); - const read = () => container.textContent; - const counts = { renders: 0 }; - const resolved: (string | null)[] = []; - let dispatch: (action: Action) => Promise = () => Promise.resolve(); - function Counter() { - counts.renders += 1; - const [state, d] = useEnhancedReducer( - (_: Action, action: Action) => action, - { n: 0 }, - [], - ); - const once = useRef(false); - if (!once.current) { - once.current = true; - d({ n: 1 }); - d({ n: 2 }).then(() => resolved.push(read())); - } - useEffect(() => { - dispatch = d; - }, [d]); - return {state.n}; - } - unmount = mount(kind, , container); - return { - counts, - resolved, - read, - dispatch: (action: Action) => dispatch(action), - }; -} - -const kinds = (['legacy', 'concurrent'] as const).filter(kind => - kind === 'legacy' ? reactMajor <= 18 : reactMajor >= 18, -); - -describe.each(kinds)('dispatch before commit on a %s root', kind => { - const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; - let prevAct: boolean | undefined; - beforeAll(() => { - prevAct = g.IS_REACT_ACT_ENVIRONMENT; - g.IS_REACT_ACT_ENVIRONMENT = false; - }); - afterAll(() => { - g.IS_REACT_ACT_ENVIRONMENT = prevAct; - }); - beforeEach(() => { - // 17 warns about updates outside act(); 18 warns that ReactDOM.render is deprecated - jest.spyOn(console, 'error').mockImplementation(() => {}); - }); - afterEach(() => { - unmount(); - jest.restoreAllMocks(); - }); - - it('replays held actions in one render from the mount effect', async () => { - const { counts, resolved, read } = setup(kind); - if (kind === 'legacy') { - // committed synchronously, mount effect still pending - expect(counts.renders).toBe(1); - expect(read()).toBe('0'); - } else { - expect(counts.renders).toBe(0); - } - await tick(); - expect(counts.renders).toBe(2); - expect(read()).toBe('2'); - expect(resolved).toEqual(['2']); - }); - - it('batches two dispatches from a promise callback only on concurrent roots', async () => { - const { counts, dispatch, read } = setup(kind); - await tick(); - const before = counts.renders; - await Promise.resolve().then(() => { - dispatch({ n: 3 }); - dispatch({ n: 4 }); - }); - await tick(); - if (kind === 'legacy') expect(counts.renders - before).toBeGreaterThan(1); - else expect(counts.renders - before).toBe(1); - expect(read()).toBe('4'); - }); -}); diff --git a/packages/use-enhanced-reducer/src/useEnhancedReducer.ts b/packages/use-enhanced-reducer/src/useEnhancedReducer.ts index 247cec4c5357..49884d6d4c49 100644 --- a/packages/use-enhanced-reducer/src/useEnhancedReducer.ts +++ b/packages/use-enhanced-reducer/src/useEnhancedReducer.ts @@ -41,31 +41,7 @@ export default function useEnhancedReducer>( const getState = useCallback(() => stateRef.current, []); - const promisifiedDispatch = usePromisifiedDispatch(realDispatch, state); - // Queue actions that reach React before this hook commits. The mount effect - // replays them, and that effect runs only once this fiber has committed, so - // the update lands on a mounted reducer. Middleware still runs immediately. - const heldRef = useRef< - null | [ReducerAction, (value: Promise | void) => void][] - >([]); - const dispatchWithPromise = useCallback( - (action: ReducerAction) => { - const held = heldRef.current; - if (held === null) return promisifiedDispatch(action); - return new Promise(resolve => { - held.push([action, resolve]); - }); - }, - [promisifiedDispatch], - ); - useEffect(() => { - const held = heldRef.current; - if (held === null) return; - heldRef.current = null; - for (const [action, resolve] of held) { - resolve(promisifiedDispatch(action)); - } - }, [promisifiedDispatch]); + const dispatchWithPromise = usePromisifiedDispatch(realDispatch, state); const outerDispatchRef = useRef>(unsetDispatch); // protected from dispatches after unmount From 8aad167647736bbd81e68fa3e5f338731ddb7aeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 05:15:06 +0000 Subject: [PATCH 09/38] fix(core): Hold fetch results until init() while DataProvider mounts 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 --- .changeset/precommit-fetch.md | 7 +- packages/core/src/controller/Controller.ts | 3 + packages/core/src/manager/NetworkManager.ts | 99 +++--------- .../src/manager/__tests__/networkManager.ts | 144 +++++++++++------- packages/core/src/manager/initManager.ts | 1 + .../react/src/components/DataProvider.tsx | 4 +- 6 files changed, 119 insertions(+), 139 deletions(-) diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md index 0f90779b6886..95c6093d84c4 100644 --- a/.changeset/precommit-fetch.md +++ b/.changeset/precommit-fetch.md @@ -8,11 +8,8 @@ '@data-client/rest': patch '@data-client/test': patch '@data-client/vue': patch -'@data-client/use-enhanced-reducer': patch --- -Fix Suspense staying on the fallback when a fetch resolves before the store commits +Fix Suspense staying on the fallback when a fetch resolves before DataProvider mounts -A [useSuspense](/docs/api/useSuspense) or `use(useFetch())` read that finishes while [DataProvider](/docs/api/DataProvider) is still rendering now shows its result once the provider commits, instead of leaving the fallback up. The endpoint is not called again. A fetch that fails is included. - -[useEnhancedReducer](https://www.npmjs.com/package/@data-client/use-enhanced-reducer) applies actions that arrive before the hook has committed, in order, from its mount effect. `dispatch` still returns a promise that resolves when that action commits. +[useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. This happens when something outside the provider suspends during the first render, or when the provider sits inside a lazy-loaded layout, as with Expo Router. The result now shows once the provider mounts, the endpoint is not called again, and failed fetches are included. diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index 932e505ecf3b..383ad1a1193b 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -100,6 +100,9 @@ export default class Controller< */ declare readonly gcPolicy: GCInterface; + /** Internal: set by a provider that will call initManager() for this controller, until it does */ + declare awaitingInit?: boolean; + constructor({ dispatch = unsetDispatch as any, getState = unsetState, diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index 2d1cbbea4f62..5830ae1bc9a7 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -22,39 +22,10 @@ export interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; + parked?: [FetchAction['endpoint'], Resolution]; } -/** Result of a throttled fetch, and the controller it was resolved into. */ -interface SettledFetch { - controller: Controller; - endpoint: FetchAction['endpoint']; - args: FetchAction['args']; - response: unknown; - fetchedAt: number; - error: boolean; -} - -const settledByMeta = new WeakMap(); - -function noteSettled( - fetching: Map, - controller: Controller, - action: FetchAction, - fetchedAt: number, - response: unknown, - error: boolean, -) { - const meta = fetching.get(action.key); - if (!meta || meta.fetchedAt !== fetchedAt) return; - settledByMeta.set(meta, { - controller, - endpoint: action.endpoint, - args: action.args, - response, - fetchedAt, - error, - }); -} +type Resolution = Parameters[1]; /** Handles all async network dispatches * @@ -137,27 +108,16 @@ export default class NetworkManager implements Manager { /** On mount */ init() { delete this.cleanupDate; - // A fetch can resolve into a store that never commits. Replay that result - // through the controller that just committed, without calling the endpoint. - for (const meta of this.fetching.values()) { - const settled = settledByMeta.get(meta); - if (!settled || settled.controller === this.controller) continue; - settled.controller = this.controller; - if (settled.error) { - this.controller.resolve(settled.endpoint, { - args: settled.args, - response: settled.response as Error, - fetchedAt: settled.fetchedAt, - error: true, - }); - } else { - this.controller.resolve(settled.endpoint, { - args: settled.args, - response: settled.response, - fetchedAt: settled.fetchedAt, - }); + // init() can run inside the host's commit; publish after it so resolve() never re-enters it + Promise.resolve().then(() => { + for (const meta of this.fetching.values()) { + const parked = meta.parked; + if (parked) { + delete meta.parked; + this.controller.resolve(parked[0], parked[1]); + } } - } + }); } /** Ensures all promises are completed by rejecting remaining. */ @@ -190,9 +150,7 @@ export default class NetworkManager implements Manager { /** Clear promise state for a given key */ protected clear(key: string) { if (this.fetching.has(key)) { - const meta = this.fetching.get(key) as FetchingMeta; - meta.promise.catch(() => {}); - settledByMeta.delete(meta); + (this.fetching.get(key) as FetchingMeta).promise.catch(() => {}); this.fetching.delete(key); } } @@ -248,16 +206,7 @@ export default class NetworkManager implements Manager { // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - if (throttle) - noteSettled( - this.fetching, - this.controller, - action, - fetchedAt, - response, - false, - ); - this.controller.resolve(action.endpoint, { + this.publish(action, { args: action.args, response, fetchedAt, @@ -269,16 +218,7 @@ export default class NetworkManager implements Manager { const lastReset = this.getLastReset(); // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - if (throttle) - noteSettled( - this.fetching, - this.controller, - action, - fetchedAt, - error, - true, - ); - this.controller.resolve(action.endpoint, { + this.publish(action, { args: action.args, response: error, fetchedAt, @@ -299,6 +239,17 @@ export default class NetworkManager implements Manager { } } + /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ + protected publish(action: FetchAction, resolution: Resolution) { + const meta = + !action.endpoint.sideEffect && + this.controller.awaitingInit && + this.fetching.get(action.key); + if (meta && meta.fetchedAt === action.meta.fetchedAt) + meta.parked = [action.endpoint, resolution]; + else this.controller.resolve(action.endpoint, resolution); + } + /** Called when middleware intercepts a set action. * * Will resolve the promise associated with set key. diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 55b6b3881eab..518cbee7d7dc 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -2,7 +2,7 @@ import { Endpoint } from '@data-client/endpoint'; import { Article, ArticleResource } from '__tests__/new'; import { SET_RESPONSE } from '../../actionTypes'; -import { createFetch } from '../../controller/actions'; +import { createFetch, createReset } from '../../controller/actions'; import Controller from '../../controller/Controller'; import NetworkManager from '../../manager/NetworkManager'; import { initialState } from '../../state/reducer/createReducer'; @@ -398,93 +398,119 @@ describe('NetworkManager', () => { }); }); -describe('NetworkManager re-resolves a fetch whose store never committed', () => { - function bind(dispatch: jest.Mock) { - return new Controller({ dispatch, getState: () => initialState }); - } - - async function start(nm: NetworkManager, endpoint: any, dispatch: jest.Mock) { - const controller = bind(dispatch); - const action = createFetch(endpoint, { args: [] }); - void Promise.resolve(action.meta.promise).catch(() => {}); - const pending = nm.middleware(controller)(jest.fn(() => Promise.resolve()))( - action, - ); - await Promise.resolve(pending).catch(() => {}); - await new Promise(resolve => setTimeout(resolve, 0)); - return { controller, action }; +describe('NetworkManager while the controller awaits init()', () => { + function bind(dispatch: jest.Mock, awaitingInit = true): Controller { + const controller = new Controller({ + dispatch, + getState: () => initialState, + }); + if (awaitingInit) controller.awaitingInit = true; + return controller; } - it('does not resolve again when the same controller commits', async () => { + function counted(settle: () => Promise, sideEffect?: true) { let calls = 0; const endpoint = new Endpoint( () => { calls += 1; - return Promise.resolve(5); + return settle(); }, - { name: 'settledSame' }, + { name: 'counted', sideEffect }, ); + return { endpoint, calls: () => calls }; + } + + async function start( + nm: NetworkManager, + { endpoint }: ReturnType, + controller: Controller, + ) { + const action = createFetch(endpoint, { args: [] }); + void Promise.resolve(action.meta.promise).catch(() => {}); + await Promise.resolve( + nm.middleware(controller)(() => Promise.resolve())(action), + ).catch(() => {}); + await new Promise(resolve => setTimeout(resolve, 0)); + } + + function commit(nm: NetworkManager, controller: Controller) { + delete controller.awaitingInit; + nm.init(); + } + + it('resolves immediately when nothing awaits init()', async () => { + const fetcher = counted(() => Promise.resolve(5)); const dispatch = jest.fn(() => Promise.resolve()); const nm = new NetworkManager(); - await start(nm, endpoint, dispatch); - expect(calls).toBe(1); - expect(dispatch).toHaveBeenCalledTimes(1); - nm.init(); + await start(nm, fetcher, bind(dispatch, false)); expect(dispatch).toHaveBeenCalledTimes(1); - expect(calls).toBe(1); nm.cleanup(); }); - it('re-resolves a response into the controller that commits', async () => { - let calls = 0; - const endpoint = new Endpoint( - () => { - calls += 1; - return Promise.resolve(5); - }, - { name: 'settledValue' }, - ); - const first = jest.fn((_action: any) => Promise.resolve()); - const second = jest.fn((_action: any) => Promise.resolve()); + it('parks a response until init(), then publishes it from a microtask', async () => { + const fetcher = counted(() => Promise.resolve(5)); + const dispatch = jest.fn((_action: any) => Promise.resolve()); + const controller = bind(dispatch); const nm = new NetworkManager(); - await start(nm, endpoint, first); - nm.middleware(bind(second)); - nm.init(); - expect(calls).toBe(1); - expect(second).toHaveBeenCalledTimes(1); - expect(second.mock.calls[0][0]).toMatchObject({ + await start(nm, fetcher, controller); + expect(dispatch).not.toHaveBeenCalled(); + commit(nm, controller); + expect(dispatch).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).toMatchObject({ type: SET_RESPONSE, response: 5, error: false, }); nm.init(); - expect(second).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(fetcher.calls()).toBe(1); nm.cleanup(); }); - it('re-resolves an error into the controller that commits', async () => { - let calls = 0; + it('publishes into the controller bound when init() runs', async () => { const failure = new Error('nope'); - const endpoint = new Endpoint( - () => { - calls += 1; - return Promise.reject(failure); - }, - { name: 'settledError' }, - ); - const first = jest.fn((_action: any) => Promise.resolve()); - const second = jest.fn((_action: any) => Promise.resolve()); + const fetcher = counted(() => Promise.reject(failure)); + const discarded = jest.fn((_action: any) => Promise.resolve()); + const committed = jest.fn((_action: any) => Promise.resolve()); const nm = new NetworkManager(); - await start(nm, endpoint, first); - nm.middleware(bind(second)); - nm.init(); - expect(calls).toBe(1); - expect(second).toHaveBeenCalledTimes(1); - expect(second.mock.calls[0][0]).toMatchObject({ + await start(nm, fetcher, bind(discarded)); + const controller = bind(committed); + nm.middleware(controller); + commit(nm, controller); + await Promise.resolve(); + expect(discarded).not.toHaveBeenCalled(); + expect(committed).toHaveBeenCalledTimes(1); + expect(committed.mock.calls[0][0]).toMatchObject({ type: SET_RESPONSE, response: failure, error: true, }); + expect(fetcher.calls()).toBe(1); + nm.cleanup(); + }); + + it('does not park mutations', async () => { + const fetcher = counted(() => Promise.resolve(5), true); + const dispatch = jest.fn(() => Promise.resolve()); + const nm = new NetworkManager(); + await start(nm, fetcher, bind(dispatch)); + expect(dispatch).toHaveBeenCalledTimes(1); + nm.cleanup(); + }); + + it('drops a parked result on RESET', async () => { + const fetcher = counted(() => Promise.resolve(5)); + const dispatch = jest.fn(() => Promise.resolve()); + const controller = bind(dispatch); + const nm = new NetworkManager(); + await start(nm, fetcher, controller); + await nm.middleware(controller)(() => Promise.resolve())(createReset()); + commit(nm, controller); + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalled(); nm.cleanup(); }); }); diff --git a/packages/core/src/manager/initManager.ts b/packages/core/src/manager/initManager.ts index 2c5aec08cd3a..058aba040283 100644 --- a/packages/core/src/manager/initManager.ts +++ b/packages/core/src/manager/initManager.ts @@ -7,6 +7,7 @@ export default function initManager( initialState: State, ) { return () => { + delete controller.awaitingInit; managers.forEach(manager => { manager.init?.(initialState); }); diff --git a/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx index 0104e0244ab2..c47bb9e72373 100644 --- a/packages/react/src/components/DataProvider.tsx +++ b/packages/react/src/components/DataProvider.tsx @@ -51,8 +51,10 @@ See https://dataclient.io/docs/guides/ssr.`, // contents of this component expected to be relatively stable const controllerRef: React.RefObject = useRef(undefined); - if (!controllerRef.current) + if (!controllerRef.current) { controllerRef.current = new Controller({ gcPolicy: gcRef.current }); + controllerRef.current.awaitingInit = true; + } //TODO: bind all methods so destructuring works const managersRef: React.RefObject = useRef(managers); From e02b82638d3f72c7a0b3a5309b806d04f909776c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 05:17:35 +0000 Subject: [PATCH 10/38] test(react): Cover DataProvider inside a lazy layout in the pre-commit 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 --- .../__tests__/precommit-race.node-suite.tsx | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 2039fa07733a..710930f37024 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -1,6 +1,7 @@ /** * Race: a fetch resolves while DataProvider has not committed, because a - * sibling `use()` is still pending. + * sibling `use()` is still pending, or because the provider's first render + * is a lazy layout's retry that React restarts. * * The filename contains `.node-` so Jest does not collect this module. * ReactDOM skips paths containing `.node`, and the Node project only @@ -12,7 +13,7 @@ */ import { Endpoint } from '@data-client/endpoint'; import { makeGate, type GatePromise } from '__tests__/streamingHarness'; -import { use, startTransition, Suspense, Component } from 'react'; +import { use, lazy, startTransition, Suspense, Component } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; @@ -121,7 +122,8 @@ function Blocker({ gate }: { gate: GatePromise }) { return null; } -type Where = 'outside' | 'inside' | 'none'; +// 'lazy': DataProvider is inside a React.lazy layout, like Expo Router's root layout +type Where = 'outside' | 'inside' | 'none' | 'lazy'; type ReaderKind = 'suspense' | 'fetch'; async function runRace( @@ -149,23 +151,36 @@ async function runRace( const managers = shared ? getDefaultManagers() : undefined; const Text = host.Text; const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; - const tree = ( - <> - - - fallback}> - - - {where === 'inside' ? - - : null} - - - {where === 'outside' ? - - : null} - + const provider = ( + + + fallback}> + + + {where === 'inside' ? + + : null} + + ); + let tree: ReactElement; + if (where === 'lazy') { + const Layout = lazy(() => gate.then(() => ({ default: () => provider }))); + tree = ( + route fallback}> + + + ); + } else { + tree = ( + <> + {provider} + {where === 'outside' ? + + : null} + + ); + } // legacy roots throw when a component suspends outside every boundary const element = LegacyReact ? @@ -177,7 +192,8 @@ async function runRace( if (transition) startTransition(() => renderer.render(element)); else renderer.render(element); // the extra tick lets the fetch settle while the render is still parked - await waitUntil(() => getCalls() > 0); + // a lazy layout cannot fetch before it loads + if (where !== 'lazy') await waitUntil(() => getCalls() > 0); await tick(); const beforeRelease = renderer.read(); gate.release(); @@ -202,7 +218,9 @@ function expectResolved( }: { shared: boolean; where: Where; settle: 'value' | 'error' }, ) { const expected = settle === 'error' ? 'error nope' : 'value 5'; - if (LegacyReact) { + if (where === 'lazy') { + expect(result.beforeRelease).toContain('route fallback'); + } else if (LegacyReact) { expect(result.beforeRelease).toContain(expected); } else if (where !== 'none') { expect(result.beforeRelease).not.toContain(expected); @@ -219,7 +237,7 @@ function expectResolved( } export function registerPrecommitRaceTests(host: RaceHost) { - describe('pre-commit fetch while a sibling use() is pending', () => { + describe('fetch that settles before DataProvider commits', () => { let prevAct: boolean | undefined; beforeAll(() => { @@ -260,6 +278,8 @@ export function registerPrecommitRaceTests(host: RaceHost) { { shared: true, where: 'outside', transition: false, reader: 'fetch' }, { shared: true, where: 'outside', transition: true, reader: 'fetch' }, { shared: false, where: 'outside', transition: false, reader: 'fetch' }, + { shared: true, where: 'lazy', transition: false, reader: 'suspense' }, + { shared: true, where: 'lazy', transition: true, reader: 'suspense' }, ]; for (const spec of cases) { @@ -274,7 +294,7 @@ export function registerPrecommitRaceTests(host: RaceHost) { ); } - it('re-resolves a rejected fetch without calling the endpoint again', async () => { + it('shows a rejected fetch without calling the endpoint again', async () => { const spec = { shared: true, where: 'outside', From d99f11f00305ca3c51e762fb3001029ba0729cb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 05:24:36 +0000 Subject: [PATCH 11/38] internal(core): Clear awaitingInit and parked results by assignment 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 --- packages/core/src/manager/NetworkManager.ts | 10 +++++----- packages/core/src/manager/initManager.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index 5830ae1bc9a7..37ffc25e8c04 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -111,10 +111,10 @@ export default class NetworkManager implements Manager { // init() can run inside the host's commit; publish after it so resolve() never re-enters it Promise.resolve().then(() => { for (const meta of this.fetching.values()) { - const parked = meta.parked; - if (parked) { - delete meta.parked; - this.controller.resolve(parked[0], parked[1]); + if (meta.parked) { + const [endpoint, resolution] = meta.parked; + meta.parked = undefined; + this.controller.resolve(endpoint, resolution); } } }); @@ -245,7 +245,7 @@ export default class NetworkManager implements Manager { !action.endpoint.sideEffect && this.controller.awaitingInit && this.fetching.get(action.key); - if (meta && meta.fetchedAt === action.meta.fetchedAt) + if (meta && meta.fetchedAt === resolution.fetchedAt) meta.parked = [action.endpoint, resolution]; else this.controller.resolve(action.endpoint, resolution); } diff --git a/packages/core/src/manager/initManager.ts b/packages/core/src/manager/initManager.ts index 058aba040283..fd70a35e1b87 100644 --- a/packages/core/src/manager/initManager.ts +++ b/packages/core/src/manager/initManager.ts @@ -7,7 +7,7 @@ export default function initManager( initialState: State, ) { return () => { - delete controller.awaitingInit; + controller.awaitingInit = false; managers.forEach(manager => { manager.init?.(initialState); }); From 6b17ca6a61f9de24541ec0d111939950544fbe1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 05:24:36 +0000 Subject: [PATCH 12/38] test(core): Drive the awaitingInit tests through initManager 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 --- .../src/manager/__tests__/networkManager.ts | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 518cbee7d7dc..58cd6aaae016 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -4,6 +4,7 @@ import { Article, ArticleResource } from '__tests__/new'; import { SET_RESPONSE } from '../../actionTypes'; import { createFetch, createReset } from '../../controller/actions'; import Controller from '../../controller/Controller'; +import initManager from '../../manager/initManager'; import NetworkManager from '../../manager/NetworkManager'; import { initialState } from '../../state/reducer/createReducer'; import { Middleware, SetResponseAction } from '../../types'; @@ -399,12 +400,12 @@ describe('NetworkManager', () => { }); describe('NetworkManager while the controller awaits init()', () => { - function bind(dispatch: jest.Mock, awaitingInit = true): Controller { + function bind(dispatch: jest.Mock): Controller { const controller = new Controller({ dispatch, getState: () => initialState, }); - if (awaitingInit) controller.awaitingInit = true; + controller.awaitingInit = true; return controller; } @@ -417,12 +418,12 @@ describe('NetworkManager while the controller awaits init()', () => { }, { name: 'counted', sideEffect }, ); - return { endpoint, calls: () => calls }; + return { endpoint, getCalls: () => calls }; } async function start( nm: NetworkManager, - { endpoint }: ReturnType, + endpoint: ReturnType['endpoint'], controller: Controller, ) { const action = createFetch(endpoint, { args: [] }); @@ -434,25 +435,28 @@ describe('NetworkManager while the controller awaits init()', () => { } function commit(nm: NetworkManager, controller: Controller) { - delete controller.awaitingInit; - nm.init(); + initManager([nm], controller, initialState)(); } it('resolves immediately when nothing awaits init()', async () => { - const fetcher = counted(() => Promise.resolve(5)); + const { endpoint } = counted(() => Promise.resolve(5)); const dispatch = jest.fn(() => Promise.resolve()); const nm = new NetworkManager(); - await start(nm, fetcher, bind(dispatch, false)); + await start( + nm, + endpoint, + new Controller({ dispatch, getState: () => initialState }), + ); expect(dispatch).toHaveBeenCalledTimes(1); nm.cleanup(); }); it('parks a response until init(), then publishes it from a microtask', async () => { - const fetcher = counted(() => Promise.resolve(5)); + const { endpoint, getCalls } = counted(() => Promise.resolve(5)); const dispatch = jest.fn((_action: any) => Promise.resolve()); const controller = bind(dispatch); const nm = new NetworkManager(); - await start(nm, fetcher, controller); + await start(nm, endpoint, controller); expect(dispatch).not.toHaveBeenCalled(); commit(nm, controller); expect(dispatch).not.toHaveBeenCalled(); @@ -466,17 +470,17 @@ describe('NetworkManager while the controller awaits init()', () => { nm.init(); await Promise.resolve(); expect(dispatch).toHaveBeenCalledTimes(1); - expect(fetcher.calls()).toBe(1); + expect(getCalls()).toBe(1); nm.cleanup(); }); it('publishes into the controller bound when init() runs', async () => { const failure = new Error('nope'); - const fetcher = counted(() => Promise.reject(failure)); + const { endpoint, getCalls } = counted(() => Promise.reject(failure)); const discarded = jest.fn((_action: any) => Promise.resolve()); const committed = jest.fn((_action: any) => Promise.resolve()); const nm = new NetworkManager(); - await start(nm, fetcher, bind(discarded)); + await start(nm, endpoint, bind(discarded)); const controller = bind(committed); nm.middleware(controller); commit(nm, controller); @@ -488,25 +492,25 @@ describe('NetworkManager while the controller awaits init()', () => { response: failure, error: true, }); - expect(fetcher.calls()).toBe(1); + expect(getCalls()).toBe(1); nm.cleanup(); }); it('does not park mutations', async () => { - const fetcher = counted(() => Promise.resolve(5), true); + const { endpoint } = counted(() => Promise.resolve(5), true); const dispatch = jest.fn(() => Promise.resolve()); const nm = new NetworkManager(); - await start(nm, fetcher, bind(dispatch)); + await start(nm, endpoint, bind(dispatch)); expect(dispatch).toHaveBeenCalledTimes(1); nm.cleanup(); }); it('drops a parked result on RESET', async () => { - const fetcher = counted(() => Promise.resolve(5)); + const { endpoint } = counted(() => Promise.resolve(5)); const dispatch = jest.fn(() => Promise.resolve()); const controller = bind(dispatch); const nm = new NetworkManager(); - await start(nm, fetcher, controller); + await start(nm, endpoint, controller); await nm.middleware(controller)(() => Promise.resolve())(createReset()); commit(nm, controller); await Promise.resolve(); From 51e97221e7025b01b71bdfa518b73a860c51b0e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 06:12:19 +0000 Subject: [PATCH 13/38] internal(core): Park fetches with the existing throttle flag handleFetch already knows whether a fetch is in the map. publish uses that flag instead of reading sideEffect again. Co-authored-by: Nathaniel Tucker --- packages/core/src/manager/NetworkManager.ts | 40 +++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index 37ffc25e8c04..df8f5d2b92cc 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -206,11 +206,15 @@ export default class NetworkManager implements Manager { // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish(action, { - args: action.args, - response, - fetchedAt, - }); + this.publish( + action, + { + args: action.args, + response, + fetchedAt, + }, + throttle, + ); } return response; }) @@ -218,12 +222,16 @@ export default class NetworkManager implements Manager { const lastReset = this.getLastReset(); // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish(action, { - args: action.args, - response: error, - fetchedAt, - error: true, - }); + this.publish( + action, + { + args: action.args, + response: error, + fetchedAt, + error: true, + }, + throttle, + ); } throw error; }); @@ -240,11 +248,13 @@ export default class NetworkManager implements Manager { } /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ - protected publish(action: FetchAction, resolution: Resolution) { + protected publish( + action: FetchAction, + resolution: Resolution, + throttle: boolean, + ) { const meta = - !action.endpoint.sideEffect && - this.controller.awaitingInit && - this.fetching.get(action.key); + throttle && this.controller.awaitingInit && this.fetching.get(action.key); if (meta && meta.fetchedAt === resolution.fetchedAt) meta.parked = [action.endpoint, resolution]; else this.controller.resolve(action.endpoint, resolution); From c543adf78d3993e800db7fc0b3d77c876221bb15 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 06:12:19 +0000 Subject: [PATCH 14/38] test(core): Let middleware failures fail the awaitingInit tests Drop the catch that swallowed a throw from the code under test, and type the dispatch mocks as SetResponseAction. Co-authored-by: Nathaniel Tucker --- .../core/src/manager/__tests__/networkManager.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 58cd6aaae016..542e1803defa 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -430,7 +430,7 @@ describe('NetworkManager while the controller awaits init()', () => { void Promise.resolve(action.meta.promise).catch(() => {}); await Promise.resolve( nm.middleware(controller)(() => Promise.resolve())(action), - ).catch(() => {}); + ); await new Promise(resolve => setTimeout(resolve, 0)); } @@ -453,7 +453,7 @@ describe('NetworkManager while the controller awaits init()', () => { it('parks a response until init(), then publishes it from a microtask', async () => { const { endpoint, getCalls } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn((_action: any) => Promise.resolve()); + const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); const controller = bind(dispatch); const nm = new NetworkManager(); await start(nm, endpoint, controller); @@ -477,8 +477,12 @@ describe('NetworkManager while the controller awaits init()', () => { it('publishes into the controller bound when init() runs', async () => { const failure = new Error('nope'); const { endpoint, getCalls } = counted(() => Promise.reject(failure)); - const discarded = jest.fn((_action: any) => Promise.resolve()); - const committed = jest.fn((_action: any) => Promise.resolve()); + const discarded = jest.fn((_action: SetResponseAction) => + Promise.resolve(), + ); + const committed = jest.fn((_action: SetResponseAction) => + Promise.resolve(), + ); const nm = new NetworkManager(); await start(nm, endpoint, bind(discarded)); const controller = bind(committed); From f1f1e8c078ff4cb5389380c115bfbd01caefb0d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 23:28:45 +0000 Subject: [PATCH 15/38] fix(core): Keep parked fetch results through cleanup() until the next 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 --- packages/core/src/manager/NetworkManager.ts | 2 ++ .../src/manager/__tests__/networkManager.ts | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index df8f5d2b92cc..f70d104581f1 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -110,6 +110,8 @@ export default class NetworkManager implements Manager { delete this.cleanupDate; // init() can run inside the host's commit; publish after it so resolve() never re-enters it Promise.resolve().then(() => { + // unmounted dispatch drops actions; leave results parked for the next init() + if (this.cleanupDate) return; for (const meta of this.fetching.values()) { if (meta.parked) { const [endpoint, resolution] = meta.parked; diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 542e1803defa..2a8b606facec 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -500,6 +500,26 @@ describe('NetworkManager while the controller awaits init()', () => { nm.cleanup(); }); + it('keeps a parked result through cleanup() until the next init()', async () => { + const { endpoint, getCalls } = counted(() => Promise.resolve(5)); + const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); + const controller = bind(dispatch); + const nm = new NetworkManager(); + await start(nm, endpoint, controller); + initManager([nm], controller, initialState)()(); + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalled(); + commit(nm, controller); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).toMatchObject({ + type: SET_RESPONSE, + response: 5, + }); + expect(getCalls()).toBe(1); + nm.cleanup(); + }); + it('does not park mutations', async () => { const { endpoint } = counted(() => Promise.resolve(5), true); const dispatch = jest.fn(() => Promise.resolve()); From 1ea7c28329d9a37a8662e1347228fd7e3d8cdaad Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 23:34:26 +0000 Subject: [PATCH 16/38] internal(website): Update playground editor types for awaitingInit and parked fetches Co-authored-by: Nathaniel Tucker --- .../Playground/editor-types/@data-client/core.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 4ecd94b5f5dd..419ab2cb5a0c 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -803,6 +803,8 @@ declare class Controller { * Handles garbage collection */ readonly gcPolicy: GCInterface; + /** Internal: set by a provider that will call initManager() for this controller, until it does */ + awaitingInit?: boolean; constructor({ dispatch, getState, memo, gcPolicy, }?: ControllerConstructorProps); set dispatch(dispatch: D); get dispatch(): D; @@ -1058,7 +1060,9 @@ interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; + parked?: [FetchAction['endpoint'], Resolution]; } +type Resolution = Parameters[1]; /** Handles all async network dispatches * * Dedupes concurrent requests by keeping track of all fetches in flight @@ -1100,6 +1104,8 @@ declare class NetworkManager implements Manager { * for ensures mutation requests always go through. */ protected handleFetch(action: FetchAction): Promise; + /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ + protected publish(action: FetchAction, resolution: Resolution, throttle: boolean): void; /** Called when middleware intercepts a set action. * * Will resolve the promise associated with set key. From 8032f4593fd1c25e7ac0013379f3efb24c5d3a91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 23:43:17 +0000 Subject: [PATCH 17/38] test(core): Publish once across a same-turn init, cleanup, init Co-authored-by: Nathaniel Tucker --- .../src/manager/__tests__/networkManager.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 2a8b606facec..9211d7c03fc2 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -520,6 +520,26 @@ describe('NetworkManager while the controller awaits init()', () => { nm.cleanup(); }); + it('publishes once across init(), cleanup(), init() in one turn', async () => { + const { endpoint, getCalls } = counted(() => Promise.resolve(5)); + const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); + const controller = bind(dispatch); + const nm = new NetworkManager(); + await start(nm, endpoint, controller); + const effect = initManager([nm], controller, initialState); + effect()(); + const cleanup = effect(); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).toMatchObject({ + type: SET_RESPONSE, + response: 5, + }); + expect(getCalls()).toBe(1); + cleanup(); + }); + it('does not park mutations', async () => { const { endpoint } = counted(() => Promise.resolve(5), true); const dispatch = jest.fn(() => Promise.resolve()); From ae95f8d428d454722e62c1b799c9eadd69d63a45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 5 Oct 2026 23:43:17 +0000 Subject: [PATCH 18/38] test(react): Cover StrictMode and an initially hidden Activity in the pre-commit race suite Co-authored-by: Nathaniel Tucker --- .../__tests__/precommit-race.node-suite.tsx | 91 ++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 710930f37024..df7c5be9dadc 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -13,7 +13,15 @@ */ import { Endpoint } from '@data-client/endpoint'; import { makeGate, type GatePromise } from '__tests__/streamingHarness'; -import { use, lazy, startTransition, Suspense, Component } from 'react'; +import { + use, + lazy, + startTransition, + Activity, + StrictMode, + Suspense, + Component, +} from 'react'; import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; @@ -134,12 +142,14 @@ async function runRace( transition, reader, settle, + strict = false, }: { shared: boolean; where: Where; transition: boolean; reader: ReaderKind; settle: 'value' | 'error'; + strict?: boolean; }, ) { const errors: unknown[][] = []; @@ -182,10 +192,11 @@ async function runRace( ); } // legacy roots throw when a component suspends outside every boundary - const element = + const root = LegacyReact ? outer}>{tree} : tree; + const element = strict ? {root} : root; const expected = settle === 'error' ? 'error nope' : 'value 5'; const renderer = host.createRenderer(); try { @@ -209,6 +220,53 @@ async function runRace( } } +// a hidden Activity renders its tree without mounting Effects, so DataProvider stays uncommitted until shown +async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { + const errors: unknown[][] = []; + const spy = jest.spyOn(console, 'error').mockImplementation((...args) => { + errors.push(args); + }); + const { endpoint, getCalls } = makeEndpoint(settle); + const managers = getDefaultManagers(); + const Text = host.Text; + const tree = (mode: 'hidden' | 'visible') => ( + + + + fallback}> + + + + + + ); + const expected = settle === 'error' ? 'error nope' : 'value 5'; + const renderer = host.createRenderer(); + try { + renderer.render(tree('hidden')); + await waitUntil(() => getCalls() > 0); + await tick(); + const beforeReveal = renderer.read(); + const callsBeforeReveal = getCalls(); + renderer.render(tree('visible')); + await waitUntil(() => renderer.read().includes(expected)); + await waitUntilStable(getCalls); + const warned = errors.some(args => + args.join(' ').includes("hasn't mounted yet"), + ); + return { + beforeReveal, + callsBeforeReveal, + text: renderer.read(), + calls: getCalls(), + warned, + }; + } finally { + renderer.unmount(); + spy.mockRestore(); + } +} + function expectResolved( result: Awaited>, { @@ -304,5 +362,34 @@ export function registerPrecommitRaceTests(host: RaceHost) { } as const; expectResolved(await runRace(host, spec), spec); }); + + for (const settle of ['value', 'error'] as const) { + const outcome = settle === 'error' ? 'an error' : 'a value'; + it(`shows ${outcome} under StrictMode without calling the endpoint again`, async () => { + const spec = { + shared: true, + where: 'outside', + transition: false, + reader: 'suspense', + settle, + strict: true, + } as const; + expectResolved(await runRace(host, spec), spec); + }); + + // Activity is React 19.2+ + (Activity !== undefined ? it : it.skip)( + `shows ${outcome} that settled inside a hidden Activity once it is revealed`, + async () => { + const expected = settle === 'error' ? 'error nope' : 'value 5'; + const result = await runHiddenActivity(host, settle); + expect(result.callsBeforeReveal).toBe(1); + expect(result.beforeReveal).not.toContain(expected); + expect(result.text).toContain(expected); + expect(result.calls).toBe(1); + expect(result.warned).toBe(false); + }, + ); + } }); } From 0253d55b584c16114a82d5611201ad66ce57b7dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 00:22:24 +0000 Subject: [PATCH 19/38] fix(core): Schedule the parked publish only when something is parked, 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 --- packages/core/src/manager/NetworkManager.ts | 43 ++++++++++---- .../src/manager/__tests__/networkManager.ts | 57 ++++++++++++++++++- 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index f70d104581f1..e6a1ae43638f 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -108,18 +108,39 @@ export default class NetworkManager implements Manager { /** On mount */ init() { delete this.cleanupDate; - // init() can run inside the host's commit; publish after it so resolve() never re-enters it - Promise.resolve().then(() => { - // unmounted dispatch drops actions; leave results parked for the next init() - if (this.cleanupDate) return; - for (const meta of this.fetching.values()) { - if (meta.parked) { - const [endpoint, resolution] = meta.parked; - meta.parked = undefined; - this.controller.resolve(endpoint, resolution); - } + for (const meta of this.fetching.values()) { + if (meta.parked) { + // init() can run inside the host's commit, where resolve() overrides such as act() throw + Promise.resolve().then(() => this.publishParked()); + return; } - }); + } + } + + /** Publishes parked results; scans again here so a RESET before this job drops them */ + protected publishParked() { + // unmounted dispatch drops actions; leave results parked for the next init() + if (this.cleanupDate) return; + for (const meta of this.fetching.values()) { + if (!meta.parked) continue; + const [endpoint, resolution] = meta.parked; + meta.parked = undefined; + try { + this.controller.resolve(endpoint, resolution); + } catch (error) { + // same as an unparked settle: a failed publish becomes the fetch's error + if (!resolution.error) + try { + this.controller.resolve(endpoint, { + ...resolution, + response: error as Error, + error: true, + }); + } catch { + // like throttle()'s fetch().catch(), a failed error publish has nowhere left to go + } + } + } } /** Ensures all promises are completed by rejecting remaining. */ diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 9211d7c03fc2..51e971ccfab5 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -1,4 +1,4 @@ -import { Endpoint } from '@data-client/endpoint'; +import { Endpoint, type EndpointInterface } from '@data-client/endpoint'; import { Article, ArticleResource } from '__tests__/new'; import { SET_RESPONSE } from '../../actionTypes'; @@ -423,7 +423,7 @@ describe('NetworkManager while the controller awaits init()', () => { async function start( nm: NetworkManager, - endpoint: ReturnType['endpoint'], + endpoint: EndpointInterface<() => Promise>, controller: Controller, ) { const action = createFetch(endpoint, { args: [] }); @@ -432,6 +432,7 @@ describe('NetworkManager while the controller awaits init()', () => { nm.middleware(controller)(() => Promise.resolve())(action), ); await new Promise(resolve => setTimeout(resolve, 0)); + return action; } function commit(nm: NetworkManager, controller: Controller) { @@ -540,6 +541,45 @@ describe('NetworkManager while the controller awaits init()', () => { cleanup(); }); + it('schedules nothing from init() when nothing is parked', async () => { + const nm = new NetworkManager(); + const { endpoint } = counted(() => new Promise(() => {})); + const controller = bind(jest.fn(() => Promise.resolve())); + await start(nm, endpoint, controller); + const resolveSpy = jest.spyOn(Promise, 'resolve'); + try { + commit(nm, controller); + expect(resolveSpy).not.toHaveBeenCalled(); + } finally { + resolveSpy.mockRestore(); + nm.cleanup(); + } + }); + + it('publishes a parked result that a middleware throws on as its error', async () => { + const failure = new Error('boom'); + const a = new Endpoint(() => Promise.resolve(5), { name: 'A' }); + const b = new Endpoint(() => Promise.resolve(6), { name: 'B' }); + const nm = new NetworkManager(); + let thrown = false; + const controller = bind( + jest.fn((action: SetResponseAction) => { + if (action.type === SET_RESPONSE && action.endpoint === a && !thrown) { + thrown = true; + throw failure; + } + return nm.middleware(controller)(() => Promise.resolve())(action); + }), + ); + const fetchA = await start(nm, a, controller); + const fetchB = await start(nm, b, controller); + commit(nm, controller); + await expect(fetchA.meta.promise).rejects.toBe(failure); + await expect(fetchB.meta.promise).resolves.toBe(6); + expect(nm['fetching'].size).toBe(0); + nm.cleanup(); + }); + it('does not park mutations', async () => { const { endpoint } = counted(() => Promise.resolve(5), true); const dispatch = jest.fn(() => Promise.resolve()); @@ -561,4 +601,17 @@ describe('NetworkManager while the controller awaits init()', () => { expect(dispatch).not.toHaveBeenCalled(); nm.cleanup(); }); + + it('drops a parked result on a RESET between init() and its publish', async () => { + const { endpoint } = counted(() => Promise.resolve(5)); + const dispatch = jest.fn(() => Promise.resolve()); + const controller = bind(dispatch); + const nm = new NetworkManager(); + await start(nm, endpoint, controller); + commit(nm, controller); + void nm.middleware(controller)(() => Promise.resolve())(createReset()); + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalled(); + nm.cleanup(); + }); }); From 47017e5ef981541e56ebbb295ffcf22087af94d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 00:23:11 +0000 Subject: [PATCH 20/38] internal(website): Update playground editor types for publishParked and the mock Controller Co-authored-by: Nathaniel Tucker --- .../components/Playground/editor-types/@data-client/core.d.ts | 2 ++ .../Playground/editor-types/@data-client/core/mock.d.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 067988007c97..be093d00b818 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -1074,6 +1074,8 @@ declare class NetworkManager implements Manager { middleware: Middleware; /** On mount */ init(): void; + /** Publishes parked results; scans again here so a RESET before this job drops them */ + protected publishParked(): void; /** Ensures all promises are completed by rejecting remaining. */ cleanup(): void; /** Used by DevtoolsManager to determine whether to log an action */ diff --git a/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts b/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts index 456a675fb788..4a4b8a054ad4 100644 --- a/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts @@ -680,6 +680,8 @@ declare class Controller { * Handles garbage collection */ readonly gcPolicy: GCInterface; + /** Internal: set by a provider that will call initManager() for this controller, until it does */ + awaitingInit?: boolean; constructor({ dispatch, getState, memo, gcPolicy, }?: ControllerConstructorProps); set dispatch(dispatch: D); get dispatch(): D; From 90e73f5fcfe6e50c51560278d3e7cfbb434c2d5b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 00:40:35 +0000 Subject: [PATCH 21/38] test(react): Reuse ErrorBoundary and the console recorder in the precommit 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 --- .../__tests__/precommit-race.node-suite.tsx | 62 +++++++------------ 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index df7c5be9dadc..d6c22dd5e2c1 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -12,7 +12,11 @@ * is suspended, so the race cannot happen there. `use()` exists from 19. */ import { Endpoint } from '@data-client/endpoint'; -import { makeGate, type GatePromise } from '__tests__/streamingHarness'; +import { + makeGate, + recordConsoleErrors, + type GatePromise, +} from '__tests__/streamingHarness'; import { use, lazy, @@ -20,12 +24,12 @@ import { Activity, StrictMode, Suspense, - Component, } from 'react'; import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; import DataProvider from '../DataProvider'; +import ErrorBoundary from '../ErrorBoundary'; import { getDefaultManagers } from '../getDefaultManagers'; import { LegacyReact } from '../LegacyReact'; @@ -82,26 +86,6 @@ function makeEndpoint(settle: 'value' | 'error') { return { endpoint, getCalls: () => calls }; } -class ErrorBox extends Component< - { children?: ReactNode; Text: RaceHost['Text'] }, - { message: string } -> { - state = { message: '' }; - - static getDerivedStateFromError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return { message }; - } - - render() { - const { Text } = this.props; - if (this.state.message) { - return {`error ${this.state.message}`}; - } - return this.props.children; - } -} - function SuspenseReader({ endpoint, Text, @@ -152,10 +136,7 @@ async function runRace( strict?: boolean; }, ) { - const errors: unknown[][] = []; - const spy = jest.spyOn(console, 'error').mockImplementation((...args) => { - errors.push(args); - }); + const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); const gate = makeGate(); const managers = shared ? getDefaultManagers() : undefined; @@ -163,14 +144,18 @@ async function runRace( const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; const provider = ( - + ( + {`error ${error.message}`} + )} + > fallback}> {where === 'inside' ? : null} - + ); let tree: ReactElement; @@ -210,33 +195,34 @@ async function runRace( gate.release(); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); - const warned = errors.some(args => + const warned = errors.unexpected.some(args => args.join(' ').includes("hasn't mounted yet"), ); return { beforeRelease, text: renderer.read(), calls: getCalls(), warned }; } finally { renderer.unmount(); - spy.mockRestore(); + errors.restore(); } } // a hidden Activity renders its tree without mounting Effects, so DataProvider stays uncommitted until shown async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { - const errors: unknown[][] = []; - const spy = jest.spyOn(console, 'error').mockImplementation((...args) => { - errors.push(args); - }); + const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); const managers = getDefaultManagers(); const Text = host.Text; const tree = (mode: 'hidden' | 'visible') => ( - + ( + {`error ${error.message}`} + )} + > fallback}> - + ); @@ -251,7 +237,7 @@ async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { renderer.render(tree('visible')); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); - const warned = errors.some(args => + const warned = errors.unexpected.some(args => args.join(' ').includes("hasn't mounted yet"), ); return { @@ -263,7 +249,7 @@ async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { }; } finally { renderer.unmount(); - spy.mockRestore(); + errors.restore(); } } From b4caf5503b2cca984ef2ce4f0cdac02b6b24b1b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:20:43 +0000 Subject: [PATCH 22/38] internal: Limit precommit changeset to core and react Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .changeset/precommit-fetch.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md index 95c6093d84c4..990788b3ee4d 100644 --- a/.changeset/precommit-fetch.md +++ b/.changeset/precommit-fetch.md @@ -1,13 +1,6 @@ --- '@data-client/core': patch -'@data-client/endpoint': patch -'@data-client/graphql': patch -'@data-client/img': patch -'@data-client/normalizr': patch '@data-client/react': patch -'@data-client/rest': patch -'@data-client/test': patch -'@data-client/vue': patch --- Fix Suspense staying on the fallback when a fetch resolves before DataProvider mounts From 74600bdb6d80f3f21e9e621e18eff5352ad26498 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 04:31:18 +0000 Subject: [PATCH 23/38] internal(core): Simplify parked publish in NetworkManager 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) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- packages/core/src/manager/NetworkManager.ts | 78 ++++++++--------- .../src/manager/__tests__/networkManager.ts | 4 +- .../__tests__/precommit-race.node-suite.tsx | 85 ++++++++----------- .../editor-types/@data-client/core.d.ts | 6 +- 4 files changed, 75 insertions(+), 98 deletions(-) diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index e6a1ae43638f..a911f68b47ce 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -22,7 +22,7 @@ export interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; - parked?: [FetchAction['endpoint'], Resolution]; + parked?: () => void; } type Resolution = Parameters[1]; @@ -122,24 +122,23 @@ export default class NetworkManager implements Manager { // unmounted dispatch drops actions; leave results parked for the next init() if (this.cleanupDate) return; for (const meta of this.fetching.values()) { - if (!meta.parked) continue; - const [endpoint, resolution] = meta.parked; + const publish = meta.parked; meta.parked = undefined; - try { - this.controller.resolve(endpoint, resolution); - } catch (error) { - // same as an unparked settle: a failed publish becomes the fetch's error - if (!resolution.error) - try { - this.controller.resolve(endpoint, { - ...resolution, - response: error as Error, - error: true, - }); - } catch { - // like throttle()'s fetch().catch(), a failed error publish has nowhere left to go - } - } + publish?.(); + } + } + + /** Like an unparked settle: a failed publish becomes the fetch's error, and a failed error publish is dropped */ + protected settle(endpoint: FetchAction['endpoint'], resolution: Resolution) { + try { + this.controller.resolve(endpoint, resolution); + } catch (error) { + if (!resolution.error) + this.settle(endpoint, { + ...resolution, + response: error as Error, + error: true, + }); } } @@ -229,15 +228,11 @@ export default class NetworkManager implements Manager { // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish( - action, - { - args: action.args, - response, - fetchedAt, - }, - throttle, - ); + this.publish(action, { + args: action.args, + response, + fetchedAt, + }); } return response; }) @@ -245,16 +240,12 @@ export default class NetworkManager implements Manager { const lastReset = this.getLastReset(); // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish( - action, - { - args: action.args, - response: error, - fetchedAt, - error: true, - }, - throttle, - ); + this.publish(action, { + args: action.args, + response: error, + fetchedAt, + error: true, + }); } throw error; }); @@ -271,15 +262,14 @@ export default class NetworkManager implements Manager { } /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ - protected publish( - action: FetchAction, - resolution: Resolution, - throttle: boolean, - ) { + protected publish(action: FetchAction, resolution: Resolution) { const meta = - throttle && this.controller.awaitingInit && this.fetching.get(action.key); + !action.endpoint.sideEffect && + this.controller.awaitingInit && + this.fetching.get(action.key); + // a newer fetch may own this key's record if (meta && meta.fetchedAt === resolution.fetchedAt) - meta.parked = [action.endpoint, resolution]; + meta.parked = () => this.settle(action.endpoint, resolution); else this.controller.resolve(action.endpoint, resolution); } diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 51e971ccfab5..4bafb2cd44a4 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -436,7 +436,7 @@ describe('NetworkManager while the controller awaits init()', () => { } function commit(nm: NetworkManager, controller: Controller) { - initManager([nm], controller, initialState)(); + return initManager([nm], controller, initialState)(); } it('resolves immediately when nothing awaits init()', async () => { @@ -507,7 +507,7 @@ describe('NetworkManager while the controller awaits init()', () => { const controller = bind(dispatch); const nm = new NetworkManager(); await start(nm, endpoint, controller); - initManager([nm], controller, initialState)()(); + commit(nm, controller)(); await Promise.resolve(); expect(dispatch).not.toHaveBeenCalled(); commit(nm, controller); diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index d6c22dd5e2c1..0083527e8f29 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -86,6 +86,10 @@ function makeEndpoint(settle: 'value' | 'error') { return { endpoint, getCalls: () => calls }; } +function expectedText(settle: 'value' | 'error') { + return settle === 'error' ? 'error nope' : 'value 5'; +} + function SuspenseReader({ endpoint, Text, @@ -182,7 +186,7 @@ async function runRace( outer}>{tree} : tree; const element = strict ? {root} : root; - const expected = settle === 'error' ? 'error nope' : 'value 5'; + const expected = expectedText(settle); const renderer = host.createRenderer(); try { if (transition) startTransition(() => renderer.render(element)); @@ -226,7 +230,7 @@ async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { ); - const expected = settle === 'error' ? 'error nope' : 'value 5'; + const expected = expectedText(settle); const renderer = host.createRenderer(); try { renderer.render(tree('hidden')); @@ -261,7 +265,7 @@ function expectResolved( settle, }: { shared: boolean; where: Where; settle: 'value' | 'error' }, ) { - const expected = settle === 'error' ? 'error nope' : 'value 5'; + const expected = expectedText(settle); if (where === 'lazy') { expect(result.beforeRelease).toContain('route fallback'); } else if (LegacyReact) { @@ -282,49 +286,43 @@ function expectResolved( export function registerPrecommitRaceTests(host: RaceHost) { describe('fetch that settles before DataProvider commits', () => { + const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; let prevAct: boolean | undefined; beforeAll(() => { - prevAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT; - ( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = false; + prevAct = g.IS_REACT_ACT_ENVIRONMENT; + g.IS_REACT_ACT_ENVIRONMENT = false; }); afterAll(() => { - ( - globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = prevAct; + g.IS_REACT_ACT_ENVIRONMENT = prevAct; }); - const cases: { + const base: { shared: boolean; where: Where; transition: boolean; reader: ReaderKind; - }[] = [ - { - shared: true, - where: 'outside', - transition: false, - reader: 'suspense', - }, - { shared: true, where: 'outside', transition: true, reader: 'suspense' }, - { - shared: false, - where: 'outside', - transition: false, - reader: 'suspense', - }, - { shared: true, where: 'inside', transition: false, reader: 'suspense' }, - { shared: true, where: 'none', transition: false, reader: 'suspense' }, - { shared: true, where: 'outside', transition: false, reader: 'fetch' }, - { shared: true, where: 'outside', transition: true, reader: 'fetch' }, - { shared: false, where: 'outside', transition: false, reader: 'fetch' }, - { shared: true, where: 'lazy', transition: false, reader: 'suspense' }, - { shared: true, where: 'lazy', transition: true, reader: 'suspense' }, - ]; + } = { + shared: true, + where: 'outside', + transition: false, + reader: 'suspense', + }; + const cases = ( + [ + {}, + { transition: true }, + { shared: false }, + { where: 'inside' }, + { where: 'none' }, + { reader: 'fetch' }, + { reader: 'fetch', transition: true }, + { reader: 'fetch', shared: false }, + { where: 'lazy' }, + { where: 'lazy', transition: true }, + ] as const + ).map(overrides => ({ ...base, ...overrides })); for (const spec of cases) { const supported = @@ -339,27 +337,14 @@ export function registerPrecommitRaceTests(host: RaceHost) { } it('shows a rejected fetch without calling the endpoint again', async () => { - const spec = { - shared: true, - where: 'outside', - transition: false, - reader: 'suspense', - settle: 'error', - } as const; + const spec = { ...base, settle: 'error' } as const; expectResolved(await runRace(host, spec), spec); }); for (const settle of ['value', 'error'] as const) { const outcome = settle === 'error' ? 'an error' : 'a value'; it(`shows ${outcome} under StrictMode without calling the endpoint again`, async () => { - const spec = { - shared: true, - where: 'outside', - transition: false, - reader: 'suspense', - settle, - strict: true, - } as const; + const spec = { ...base, settle, strict: true }; expectResolved(await runRace(host, spec), spec); }); @@ -367,7 +352,7 @@ export function registerPrecommitRaceTests(host: RaceHost) { (Activity !== undefined ? it : it.skip)( `shows ${outcome} that settled inside a hidden Activity once it is revealed`, async () => { - const expected = settle === 'error' ? 'error nope' : 'value 5'; + const expected = expectedText(settle); const result = await runHiddenActivity(host, settle); expect(result.callsBeforeReveal).toBe(1); expect(result.beforeReveal).not.toContain(expected); diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index be093d00b818..2bc31ee7ed29 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -1049,7 +1049,7 @@ interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; - parked?: [FetchAction['endpoint'], Resolution]; + parked?: () => void; } type Resolution = Parameters[1]; /** Handles all async network dispatches @@ -1076,6 +1076,8 @@ declare class NetworkManager implements Manager { init(): void; /** Publishes parked results; scans again here so a RESET before this job drops them */ protected publishParked(): void; + /** Like an unparked settle: a failed publish becomes the fetch's error, and a failed error publish is dropped */ + protected settle(endpoint: FetchAction['endpoint'], resolution: Resolution): void; /** Ensures all promises are completed by rejecting remaining. */ cleanup(): void; /** Used by DevtoolsManager to determine whether to log an action */ @@ -1096,7 +1098,7 @@ declare class NetworkManager implements Manager { */ protected handleFetch(action: FetchAction): Promise; /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ - protected publish(action: FetchAction, resolution: Resolution, throttle: boolean): void; + protected publish(action: FetchAction, resolution: Resolution): void; /** Called when middleware intercepts a set action. * * Will resolve the promise associated with set key. From c15fc64b07cd89c997f339b67cf07245ced08753 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 17:21:19 +0000 Subject: [PATCH 24/38] experiment(react): Move pre-commit protection into an opt-in CommitGateManager Replaces the NetworkManager parking and Controller.awaitingInit flag with a manager that holds SET_RESPONSE until init() and replays it into the mounted store. React Native's default managers include it; web opts in. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .changeset/precommit-fetch.md | 11 +- packages/core/src/controller/Controller.ts | 3 - packages/core/src/manager/NetworkManager.ts | 51 +--- .../src/manager/__tests__/networkManager.ts | 222 +----------------- packages/core/src/manager/initManager.ts | 1 - .../react/src/components/DataProvider.tsx | 4 +- .../__tests__/precommit-race.node-suite.tsx | 36 ++- .../src/components/getDefaultManagers.tsx | 17 +- .../src/components/platformManagers.native.ts | 8 + .../react/src/components/platformManagers.ts | 6 + .../react/src/managers/CommitGateManager.ts | 60 +++++ .../__tests__/CommitGateManager.web.ts | 82 +++++++ packages/react/src/managers/index.ts | 1 + .../editor-types/@data-client/core.d.ts | 10 - .../editor-types/@data-client/core/mock.d.ts | 2 - 15 files changed, 212 insertions(+), 302 deletions(-) create mode 100644 packages/react/src/components/platformManagers.native.ts create mode 100644 packages/react/src/components/platformManagers.ts create mode 100644 packages/react/src/managers/CommitGateManager.ts create mode 100644 packages/react/src/managers/__tests__/CommitGateManager.web.ts diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md index 990788b3ee4d..8e1432fb166d 100644 --- a/.changeset/precommit-fetch.md +++ b/.changeset/precommit-fetch.md @@ -1,8 +1,13 @@ --- -'@data-client/core': patch '@data-client/react': patch --- -Fix Suspense staying on the fallback when a fetch resolves before DataProvider mounts +Fix Suspense staying on the fallback when a fetch resolves before DataProvider mounts on React Native -[useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. This happens when something outside the provider suspends during the first render, or when the provider sits inside a lazy-loaded layout, as with Expo Router. The result now shows once the provider mounts, the endpoint is not called again, and failed fetches are included. +[useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. This happens when the provider sits inside a lazy-loaded layout, as with Expo Router, or when something outside the provider suspends during the first render. + +React Native's default managers now include `CommitGateManager`, which holds those results until the provider mounts. On web, add it before the other managers when a provider can render inside a lazy layout or next to a suspending sibling: + +```tsx + +``` diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts index f4411bcf6bbf..c40811bd602f 100644 --- a/packages/core/src/controller/Controller.ts +++ b/packages/core/src/controller/Controller.ts @@ -106,9 +106,6 @@ export default class Controller< */ declare readonly gcPolicy: GCInterface; - /** Internal: set by a provider that will call initManager() for this controller, until it does */ - declare awaitingInit?: boolean; - constructor({ dispatch = unsetDispatch as any, getState = unsetState, diff --git a/packages/core/src/manager/NetworkManager.ts b/packages/core/src/manager/NetworkManager.ts index a911f68b47ce..d378a0547082 100644 --- a/packages/core/src/manager/NetworkManager.ts +++ b/packages/core/src/manager/NetworkManager.ts @@ -22,11 +22,8 @@ export interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; - parked?: () => void; } -type Resolution = Parameters[1]; - /** Handles all async network dispatches * * Dedupes concurrent requests by keeping track of all fetches in flight @@ -108,38 +105,6 @@ export default class NetworkManager implements Manager { /** On mount */ init() { delete this.cleanupDate; - for (const meta of this.fetching.values()) { - if (meta.parked) { - // init() can run inside the host's commit, where resolve() overrides such as act() throw - Promise.resolve().then(() => this.publishParked()); - return; - } - } - } - - /** Publishes parked results; scans again here so a RESET before this job drops them */ - protected publishParked() { - // unmounted dispatch drops actions; leave results parked for the next init() - if (this.cleanupDate) return; - for (const meta of this.fetching.values()) { - const publish = meta.parked; - meta.parked = undefined; - publish?.(); - } - } - - /** Like an unparked settle: a failed publish becomes the fetch's error, and a failed error publish is dropped */ - protected settle(endpoint: FetchAction['endpoint'], resolution: Resolution) { - try { - this.controller.resolve(endpoint, resolution); - } catch (error) { - if (!resolution.error) - this.settle(endpoint, { - ...resolution, - response: error as Error, - error: true, - }); - } } /** Ensures all promises are completed by rejecting remaining. */ @@ -228,7 +193,7 @@ export default class NetworkManager implements Manager { // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish(action, { + this.controller.resolve(action.endpoint, { args: action.args, response, fetchedAt, @@ -240,7 +205,7 @@ export default class NetworkManager implements Manager { const lastReset = this.getLastReset(); // don't update state with promises started before last clear if (fetchedAt >= lastReset) { - this.publish(action, { + this.controller.resolve(action.endpoint, { args: action.args, response: error, fetchedAt, @@ -261,18 +226,6 @@ export default class NetworkManager implements Manager { } } - /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ - protected publish(action: FetchAction, resolution: Resolution) { - const meta = - !action.endpoint.sideEffect && - this.controller.awaitingInit && - this.fetching.get(action.key); - // a newer fetch may own this key's record - if (meta && meta.fetchedAt === resolution.fetchedAt) - meta.parked = () => this.settle(action.endpoint, resolution); - else this.controller.resolve(action.endpoint, resolution); - } - /** Called when middleware intercepts a set action. * * Will resolve the promise associated with set key. diff --git a/packages/core/src/manager/__tests__/networkManager.ts b/packages/core/src/manager/__tests__/networkManager.ts index 4bafb2cd44a4..6e6ace650666 100644 --- a/packages/core/src/manager/__tests__/networkManager.ts +++ b/packages/core/src/manager/__tests__/networkManager.ts @@ -1,10 +1,9 @@ -import { Endpoint, type EndpointInterface } from '@data-client/endpoint'; +import { Endpoint } from '@data-client/endpoint'; import { Article, ArticleResource } from '__tests__/new'; import { SET_RESPONSE } from '../../actionTypes'; -import { createFetch, createReset } from '../../controller/actions'; +import { createFetch } from '../../controller/actions'; import Controller from '../../controller/Controller'; -import initManager from '../../manager/initManager'; import NetworkManager from '../../manager/NetworkManager'; import { initialState } from '../../state/reducer/createReducer'; import { Middleware, SetResponseAction } from '../../types'; @@ -398,220 +397,3 @@ describe('NetworkManager', () => { }); }); }); - -describe('NetworkManager while the controller awaits init()', () => { - function bind(dispatch: jest.Mock): Controller { - const controller = new Controller({ - dispatch, - getState: () => initialState, - }); - controller.awaitingInit = true; - return controller; - } - - function counted(settle: () => Promise, sideEffect?: true) { - let calls = 0; - const endpoint = new Endpoint( - () => { - calls += 1; - return settle(); - }, - { name: 'counted', sideEffect }, - ); - return { endpoint, getCalls: () => calls }; - } - - async function start( - nm: NetworkManager, - endpoint: EndpointInterface<() => Promise>, - controller: Controller, - ) { - const action = createFetch(endpoint, { args: [] }); - void Promise.resolve(action.meta.promise).catch(() => {}); - await Promise.resolve( - nm.middleware(controller)(() => Promise.resolve())(action), - ); - await new Promise(resolve => setTimeout(resolve, 0)); - return action; - } - - function commit(nm: NetworkManager, controller: Controller) { - return initManager([nm], controller, initialState)(); - } - - it('resolves immediately when nothing awaits init()', async () => { - const { endpoint } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn(() => Promise.resolve()); - const nm = new NetworkManager(); - await start( - nm, - endpoint, - new Controller({ dispatch, getState: () => initialState }), - ); - expect(dispatch).toHaveBeenCalledTimes(1); - nm.cleanup(); - }); - - it('parks a response until init(), then publishes it from a microtask', async () => { - const { endpoint, getCalls } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); - const controller = bind(dispatch); - const nm = new NetworkManager(); - await start(nm, endpoint, controller); - expect(dispatch).not.toHaveBeenCalled(); - commit(nm, controller); - expect(dispatch).not.toHaveBeenCalled(); - await Promise.resolve(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch.mock.calls[0][0]).toMatchObject({ - type: SET_RESPONSE, - response: 5, - error: false, - }); - nm.init(); - await Promise.resolve(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(getCalls()).toBe(1); - nm.cleanup(); - }); - - it('publishes into the controller bound when init() runs', async () => { - const failure = new Error('nope'); - const { endpoint, getCalls } = counted(() => Promise.reject(failure)); - const discarded = jest.fn((_action: SetResponseAction) => - Promise.resolve(), - ); - const committed = jest.fn((_action: SetResponseAction) => - Promise.resolve(), - ); - const nm = new NetworkManager(); - await start(nm, endpoint, bind(discarded)); - const controller = bind(committed); - nm.middleware(controller); - commit(nm, controller); - await Promise.resolve(); - expect(discarded).not.toHaveBeenCalled(); - expect(committed).toHaveBeenCalledTimes(1); - expect(committed.mock.calls[0][0]).toMatchObject({ - type: SET_RESPONSE, - response: failure, - error: true, - }); - expect(getCalls()).toBe(1); - nm.cleanup(); - }); - - it('keeps a parked result through cleanup() until the next init()', async () => { - const { endpoint, getCalls } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); - const controller = bind(dispatch); - const nm = new NetworkManager(); - await start(nm, endpoint, controller); - commit(nm, controller)(); - await Promise.resolve(); - expect(dispatch).not.toHaveBeenCalled(); - commit(nm, controller); - await Promise.resolve(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch.mock.calls[0][0]).toMatchObject({ - type: SET_RESPONSE, - response: 5, - }); - expect(getCalls()).toBe(1); - nm.cleanup(); - }); - - it('publishes once across init(), cleanup(), init() in one turn', async () => { - const { endpoint, getCalls } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn((_action: SetResponseAction) => Promise.resolve()); - const controller = bind(dispatch); - const nm = new NetworkManager(); - await start(nm, endpoint, controller); - const effect = initManager([nm], controller, initialState); - effect()(); - const cleanup = effect(); - await Promise.resolve(); - await Promise.resolve(); - expect(dispatch).toHaveBeenCalledTimes(1); - expect(dispatch.mock.calls[0][0]).toMatchObject({ - type: SET_RESPONSE, - response: 5, - }); - expect(getCalls()).toBe(1); - cleanup(); - }); - - it('schedules nothing from init() when nothing is parked', async () => { - const nm = new NetworkManager(); - const { endpoint } = counted(() => new Promise(() => {})); - const controller = bind(jest.fn(() => Promise.resolve())); - await start(nm, endpoint, controller); - const resolveSpy = jest.spyOn(Promise, 'resolve'); - try { - commit(nm, controller); - expect(resolveSpy).not.toHaveBeenCalled(); - } finally { - resolveSpy.mockRestore(); - nm.cleanup(); - } - }); - - it('publishes a parked result that a middleware throws on as its error', async () => { - const failure = new Error('boom'); - const a = new Endpoint(() => Promise.resolve(5), { name: 'A' }); - const b = new Endpoint(() => Promise.resolve(6), { name: 'B' }); - const nm = new NetworkManager(); - let thrown = false; - const controller = bind( - jest.fn((action: SetResponseAction) => { - if (action.type === SET_RESPONSE && action.endpoint === a && !thrown) { - thrown = true; - throw failure; - } - return nm.middleware(controller)(() => Promise.resolve())(action); - }), - ); - const fetchA = await start(nm, a, controller); - const fetchB = await start(nm, b, controller); - commit(nm, controller); - await expect(fetchA.meta.promise).rejects.toBe(failure); - await expect(fetchB.meta.promise).resolves.toBe(6); - expect(nm['fetching'].size).toBe(0); - nm.cleanup(); - }); - - it('does not park mutations', async () => { - const { endpoint } = counted(() => Promise.resolve(5), true); - const dispatch = jest.fn(() => Promise.resolve()); - const nm = new NetworkManager(); - await start(nm, endpoint, bind(dispatch)); - expect(dispatch).toHaveBeenCalledTimes(1); - nm.cleanup(); - }); - - it('drops a parked result on RESET', async () => { - const { endpoint } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn(() => Promise.resolve()); - const controller = bind(dispatch); - const nm = new NetworkManager(); - await start(nm, endpoint, controller); - await nm.middleware(controller)(() => Promise.resolve())(createReset()); - commit(nm, controller); - await Promise.resolve(); - expect(dispatch).not.toHaveBeenCalled(); - nm.cleanup(); - }); - - it('drops a parked result on a RESET between init() and its publish', async () => { - const { endpoint } = counted(() => Promise.resolve(5)); - const dispatch = jest.fn(() => Promise.resolve()); - const controller = bind(dispatch); - const nm = new NetworkManager(); - await start(nm, endpoint, controller); - commit(nm, controller); - void nm.middleware(controller)(() => Promise.resolve())(createReset()); - await Promise.resolve(); - expect(dispatch).not.toHaveBeenCalled(); - nm.cleanup(); - }); -}); diff --git a/packages/core/src/manager/initManager.ts b/packages/core/src/manager/initManager.ts index fd70a35e1b87..2c5aec08cd3a 100644 --- a/packages/core/src/manager/initManager.ts +++ b/packages/core/src/manager/initManager.ts @@ -7,7 +7,6 @@ export default function initManager( initialState: State, ) { return () => { - controller.awaitingInit = false; managers.forEach(manager => { manager.init?.(initialState); }); diff --git a/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx index c47bb9e72373..0104e0244ab2 100644 --- a/packages/react/src/components/DataProvider.tsx +++ b/packages/react/src/components/DataProvider.tsx @@ -51,10 +51,8 @@ See https://dataclient.io/docs/guides/ssr.`, // contents of this component expected to be relatively stable const controllerRef: React.RefObject = useRef(undefined); - if (!controllerRef.current) { + if (!controllerRef.current) controllerRef.current = new Controller({ gcPolicy: gcRef.current }); - controllerRef.current.awaitingInit = true; - } //TODO: bind all methods so destructuring works const managersRef: React.RefObject = useRef(managers); diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 0083527e8f29..47d8b27d4be7 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -21,6 +21,7 @@ import { use, lazy, startTransition, + useState, Activity, StrictMode, Suspense, @@ -30,6 +31,7 @@ import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; import DataProvider from '../DataProvider'; import ErrorBoundary from '../ErrorBoundary'; +import { CommitGateManager } from '../../managers'; import { getDefaultManagers } from '../getDefaultManagers'; import { LegacyReact } from '../LegacyReact'; @@ -112,6 +114,24 @@ function FetchReader({ return {`value ${value}`}; } +// web opts in; native's defaults already include the gate +function gatedManagers() { + const managers = getDefaultManagers(); + return managers.some(m => m instanceof CommitGateManager) ? managers : ( + [new CommitGateManager(), ...managers] + ); +} + +// like DataProvider's own defaults: every render that is thrown away builds new managers +function FreshDataProvider({ children }: { children: ReactNode }) { + const [managers] = useState(gatedManagers); + return ( + + {children} + + ); +} + function Blocker({ gate }: { gate: GatePromise }) { if (use) use(gate); else if (!gate.done) throw gate; @@ -143,11 +163,19 @@ async function runRace( const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); const gate = makeGate(); - const managers = shared ? getDefaultManagers() : undefined; + const managers = shared ? gatedManagers() : undefined; + const Provider = + managers ? + ({ children }: { children: ReactNode }) => ( + + {children} + + ) + : FreshDataProvider; const Text = host.Text; const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; const provider = ( - + ( {`error ${error.message}`} @@ -160,7 +188,7 @@ async function runRace( : null} - + ); let tree: ReactElement; if (where === 'lazy') { @@ -213,7 +241,7 @@ async function runRace( async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); - const managers = getDefaultManagers(); + const managers = gatedManagers(); const Text = host.Text; const tree = (mode: 'hidden' | 'visible') => ( diff --git a/packages/react/src/components/getDefaultManagers.tsx b/packages/react/src/components/getDefaultManagers.tsx index 980be74c3efb..36cdba541551 100644 --- a/packages/react/src/components/getDefaultManagers.tsx +++ b/packages/react/src/components/getDefaultManagers.tsx @@ -6,6 +6,7 @@ import { PollingSubscription, DevToolsManager, } from '../managers/index.js'; +import platformManagers from './platformManagers.js'; /* istanbul ignore next */ /** Returns the default Managers used by DataProvider. @@ -20,12 +21,14 @@ let getDefaultManagers: ({ networkManager, subscriptionManager = PollingSubscription, } = {}) => - subscriptionManager === null ? - [constructManager(NetworkManager, networkManager)] - : [ - constructManager(NetworkManager, networkManager), - constructManager(SubscriptionManager, subscriptionManager), - ]; + platformManagers( + subscriptionManager === null ? + [constructManager(NetworkManager, networkManager)] + : [ + constructManager(NetworkManager, networkManager), + constructManager(SubscriptionManager, subscriptionManager), + ], + ); /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { getDefaultManagers = ({ @@ -49,7 +52,7 @@ if (process.env.NODE_ENV !== 'production') { ), ); } - return managers; + return platformManagers(managers); }; } export { getDefaultManagers }; diff --git a/packages/react/src/components/platformManagers.native.ts b/packages/react/src/components/platformManagers.native.ts new file mode 100644 index 000000000000..649073e2d51a --- /dev/null +++ b/packages/react/src/components/platformManagers.native.ts @@ -0,0 +1,8 @@ +import type { Manager } from '@data-client/core'; + +import CommitGateManager from '../managers/CommitGateManager.js'; + +/** Expo Router loads the root layout lazily, so fetches can settle before DataProvider mounts */ +export default function platformManagers(managers: Manager[]): Manager[] { + return [new CommitGateManager(), ...managers]; +} diff --git a/packages/react/src/components/platformManagers.ts b/packages/react/src/components/platformManagers.ts new file mode 100644 index 000000000000..df77ca926b40 --- /dev/null +++ b/packages/react/src/components/platformManagers.ts @@ -0,0 +1,6 @@ +import type { Manager } from '@data-client/core'; + +/** Web needs no platform managers; see platformManagers.native.ts */ +export default function platformManagers(managers: Manager[]): Manager[] { + return managers; +} diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts new file mode 100644 index 000000000000..cbb2d40464ed --- /dev/null +++ b/packages/react/src/managers/CommitGateManager.ts @@ -0,0 +1,60 @@ +import { actionTypes } from '@data-client/core'; +import type { + ActionTypes, + Controller, + Manager, + Middleware, +} from '@data-client/core'; + +/** Holds fetch results until DataProvider mounts + * + * A fetch can settle before its DataProvider commits, when something outside it + * suspends or when the provider sits in a lazy layout (as with Expo Router). React + * discards that render's store, so the result would land nowhere. This holds + * results until init() and replays them into the store that mounted. + * + * Place it before NetworkManager, and share it with the managers it protects. + * @see https://dataclient.io/docs/api/CommitGateManager + */ +export default class CommitGateManager implements Manager { + protected held: [ActionTypes, () => void][] = []; + protected controller?: Controller; + protected mounted?: Controller; + + middleware: Middleware = controller => { + this.controller = controller; + return next => action => { + if (controller === this.mounted) { + if (action.type === actionTypes.RESET) this.drop(); + return next(action); + } + if (action.type !== actionTypes.SET_RESPONSE) return next(action); + return new Promise(resolve => this.held.push([action, resolve])); + }; + }; + + init() { + this.mounted = this.controller; + // init() runs in the host's commit, where dispatch overrides such as act() throw + if (this.held.length) Promise.resolve().then(() => this.replay()); + } + + cleanup() { + this.mounted = undefined; + } + + protected replay() { + const mounted = this.mounted; + // unmounted again before this job; keep holding for the next init() + if (!mounted) return; + const held = this.held; + this.held = []; + for (const [action, resolve] of held) + mounted.dispatch(action).then(resolve); + } + + protected drop() { + for (const [, resolve] of this.held) resolve(); + this.held = []; + } +} diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts new file mode 100644 index 000000000000..02858945f53c --- /dev/null +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -0,0 +1,82 @@ +import { + Controller, + actions, + initManager, + initialState, +} from '@data-client/core'; +import type { Middleware } from '@data-client/core'; +import { Endpoint } from '@data-client/endpoint'; + +import { CommitGateManager } from '..'; + +const endpoint = new Endpoint(() => Promise.resolve(5), { name: 'gated' }); + +function setResponse() { + return actions.createSetResponse(endpoint, { + args: [], + response: 5, + fetchedAt: Date.now(), + }); +} + +function bind(gate: CommitGateManager) { + const reduced = jest.fn((_action: unknown) => Promise.resolve()); + const controller = new Controller(); + // like a provider's render, which builds its middleware chain once + const dispatch = (gate.middleware as Middleware)(controller)(reduced); + controller.bindMiddleware({ dispatch, getState: () => initialState }); + return { + controller, + reduced, + mount: initManager([gate], controller, initialState), + }; +} + +describe('CommitGateManager', () => { + it('holds results until init(), then replays them into the mounted store', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const held = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + committed.mount(); + expect(committed.reduced).not.toHaveBeenCalled(); + await held; + expect(discarded.reduced).not.toHaveBeenCalled(); + expect(committed.reduced).toHaveBeenCalledTimes(1); + }); + + it('passes other actions and results once mounted', () => { + const gate = new CommitGateManager(); + const { controller, reduced, mount } = bind(gate); + void controller.dispatch(actions.createReset()); + mount(); + void controller.dispatch(setResponse()); + expect(reduced).toHaveBeenCalledTimes(2); + }); + + it('keeps holding when unmounted before the replay', async () => { + const gate = new CommitGateManager(); + const { controller, reduced, mount } = bind(gate); + void controller.dispatch(setResponse()); + mount()(); + await Promise.resolve(); + expect(reduced).not.toHaveBeenCalled(); + mount(); + await Promise.resolve(); + expect(reduced).toHaveBeenCalledTimes(1); + }); + + it('drops held results on RESET', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const held = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + committed.mount(); + void committed.controller.dispatch(actions.createReset()); + await held; + expect(committed.reduced).toHaveBeenCalledTimes(1); + expect(committed.reduced.mock.calls[0][0]).toMatchObject({ + type: 'rdc/reset', + }); + }); +}); diff --git a/packages/react/src/managers/index.ts b/packages/react/src/managers/index.ts index c7d9fa0543a4..588863285ba9 100644 --- a/packages/react/src/managers/index.ts +++ b/packages/react/src/managers/index.ts @@ -7,3 +7,4 @@ export { NetworkManager, } from '@data-client/core'; export { default as IdlingNetworkManager } from './IdlingNetworkManager.js'; +export { default as CommitGateManager } from './CommitGateManager.js'; diff --git a/website/src/components/Playground/editor-types/@data-client/core.d.ts b/website/src/components/Playground/editor-types/@data-client/core.d.ts index 2bc31ee7ed29..bceab8c9bba3 100644 --- a/website/src/components/Playground/editor-types/@data-client/core.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core.d.ts @@ -792,8 +792,6 @@ declare class Controller { * Handles garbage collection */ readonly gcPolicy: GCInterface; - /** Internal: set by a provider that will call initManager() for this controller, until it does */ - awaitingInit?: boolean; constructor({ dispatch, getState, memo, gcPolicy, }?: ControllerConstructorProps); set dispatch(dispatch: D); get dispatch(): D; @@ -1049,9 +1047,7 @@ interface FetchingMeta { resolve: (value?: any) => void; reject: (value?: any) => void; fetchedAt: number; - parked?: () => void; } -type Resolution = Parameters[1]; /** Handles all async network dispatches * * Dedupes concurrent requests by keeping track of all fetches in flight @@ -1074,10 +1070,6 @@ declare class NetworkManager implements Manager { middleware: Middleware; /** On mount */ init(): void; - /** Publishes parked results; scans again here so a RESET before this job drops them */ - protected publishParked(): void; - /** Like an unparked settle: a failed publish becomes the fetch's error, and a failed error publish is dropped */ - protected settle(endpoint: FetchAction['endpoint'], resolution: Resolution): void; /** Ensures all promises are completed by rejecting remaining. */ cleanup(): void; /** Used by DevtoolsManager to determine whether to log an action */ @@ -1097,8 +1089,6 @@ declare class NetworkManager implements Manager { * for ensures mutation requests always go through. */ protected handleFetch(action: FetchAction): Promise; - /** Resolves a fetch, or parks a throttled result on its record until init() if the controller awaits it */ - protected publish(action: FetchAction, resolution: Resolution): void; /** Called when middleware intercepts a set action. * * Will resolve the promise associated with set key. diff --git a/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts b/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts index 4a4b8a054ad4..456a675fb788 100644 --- a/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/core/mock.d.ts @@ -680,8 +680,6 @@ declare class Controller { * Handles garbage collection */ readonly gcPolicy: GCInterface; - /** Internal: set by a provider that will call initManager() for this controller, until it does */ - awaitingInit?: boolean; constructor({ dispatch, getState, memo, gcPolicy, }?: ControllerConstructorProps); set dispatch(dispatch: D); get dispatch(): D; From 88bb9c35854800478f2d39487c85087de1a0b7cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:32:14 +0000 Subject: [PATCH 25/38] docs: Add CommitGateManager Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../references/getDefaultManagers.md | 2 + .../references/getDefaultManagers.md | 2 + docs/core/api/CommitGateManager.md | 61 +++++++++++++++++++ docs/core/api/getDefaultManagers.md | 6 ++ website/sidebars.json | 4 ++ .../editor-types/@data-client/react.d.ts | 25 +++++++- 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 docs/core/api/CommitGateManager.md diff --git a/.agents/skills/data-client-manager/references/getDefaultManagers.md b/.agents/skills/data-client-manager/references/getDefaultManagers.md index d32abd15082d..64b219012a29 100644 --- a/.agents/skills/data-client-manager/references/getDefaultManagers.md +++ b/.agents/skills/data-client-manager/references/getDefaultManagers.md @@ -11,6 +11,8 @@ Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsMan \*(`DevToolsManager` is excluded in production builds.) +On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) comes first. + ## Usage **Web** diff --git a/.agents/skills/data-client-setup/references/getDefaultManagers.md b/.agents/skills/data-client-setup/references/getDefaultManagers.md index 81682d009ed4..45e205cf0000 100644 --- a/.agents/skills/data-client-setup/references/getDefaultManagers.md +++ b/.agents/skills/data-client-setup/references/getDefaultManagers.md @@ -11,6 +11,8 @@ Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsMan \*(`DevToolsManager` is excluded in production builds.) +On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) comes first. + ## Usage **Web** diff --git a/docs/core/api/CommitGateManager.md b/docs/core/api/CommitGateManager.md new file mode 100644 index 000000000000..d8fd84869589 --- /dev/null +++ b/docs/core/api/CommitGateManager.md @@ -0,0 +1,61 @@ +--- +title: CommitGateManager - Keep fetches that resolve before DataProvider mounts +sidebar_label: CommitGateManager +frameworks: [react] +--- + +import ProviderManagers from '../shared/_provider_managers.mdx'; + +# CommitGateManager + +Holds fetch results that arrive before [<DataProvider />](./DataProvider.md) mounts, then +applies them once it does. + +React can render a `DataProvider` and then throw that render away. This happens when the provider +sits inside a lazy-loaded layout, or when something outside the provider suspends during its first +render. A fetch that resolves in between has no store to land in, so [useSuspense()](./useSuspense.md) +would stay on its fallback. `CommitGateManager` keeps those results and replays them into the store +that mounts, so the endpoint is called only once. + +:::info implements + +`CommitGateManager` implements [Manager](./Manager.md) + +::: + +## Usage + +React Native's [getDefaultManagers()](./getDefaultManagers.md) already includes it, since +[Expo Router](https://docs.expo.dev/router/introduction/) loads the root layout lazily. + +On web, add it first when `DataProvider` renders inside a [lazy](https://react.dev/reference/react/lazy) +layout or route, or next to a component that can suspend outside it. + + + +```ts +// highlight-next-line +const managers = [new CommitGateManager(), ...getDefaultManagers()]; +``` + + + +:::tip + +Placing `DataProvider` at the root, with every [Suspense](https://react.dev/reference/react/Suspense) +boundary inside it, avoids the race without this manager. + +::: + +## Members + +### middleware + +#### Processed Actions + +- [setResponse](./Controller.md#setResponse) is held until the provider mounts. +- [resetEntireStore](./Controller.md#resetEntireStore) drops held results. + +#### Dispatched Actions + +- [setResponse](./Controller.md#setResponse) replays each held result into the mounted store. diff --git a/docs/core/api/getDefaultManagers.md b/docs/core/api/getDefaultManagers.md index 234789e91905..f1211c2468ea 100644 --- a/docs/core/api/getDefaultManagers.md +++ b/docs/core/api/getDefaultManagers.md @@ -18,6 +18,12 @@ Currently returns \[[DevToolsManager](./DevToolsManager.md)\*, [NetworkManager]( \*(`DevToolsManager` is excluded in production builds.) +:::react + +On React Native, [CommitGateManager](./CommitGateManager.md) comes first. + +::: + ## Usage diff --git a/website/sidebars.json b/website/sidebars.json index ad20b2a0f554..d1f1161f4fee 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -259,6 +259,10 @@ "type": "doc", "id": "api/NetworkManager" }, + { + "type": "doc", + "id": "api/CommitGateManager" + }, { "type": "doc", "id": "api/SubscriptionManager" diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index 218667c8cb94..e599cb9def8a 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -1,5 +1,5 @@ import * as _data_client_core from '@data-client/core'; -import { NetworkManager, Manager, State, Controller, GCInterface, DevToolsManager, DevToolsConfig, SubscriptionManager, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, actions, applyManager, createReducer, initManager } from '@data-client/core'; +import { NetworkManager, Manager, ActionTypes, Controller, Middleware, State, GCInterface, DevToolsManager, DevToolsConfig, SubscriptionManager, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, actions, applyManager, createReducer, initManager } from '@data-client/core'; export { AbstractInstanceType, ActionTypes, Controller, CreateCountRef, DataClientDispatch, DefaultConnectionListener, Denormalize, DenormalizeNullable, DevToolsManager, Dispatch, EndpointExtraOptions, EndpointInterface, EntityInterface, ErrorTypes, ExpiryStatus, FetchAction, FetchFunction, GCInterface, GCOptions, GCPolicy, GenericDispatch, InvalidateAction, LogoutManager, Manager, Middleware, MiddlewareAPI, NetworkError, NetworkManager, Normalize, NormalizeNullable, PK, PollingSubscription, Queryable, ResetAction, ResolveType, Schema, SchemaArgs, SchemaClass, SetAction, SetResponseAction, State, SubscribeAction, SubscriptionManager, UnknownError, UnsubscribeAction, UpdateFunction, actionTypes } from '@data-client/core'; import * as React from 'react'; import React__default, { JSX, Context } from 'react'; @@ -8,6 +8,27 @@ import React__default, { JSX, Context } from 'react'; declare class WebIdlingNetworkManager extends NetworkManager { } +/** Holds fetch results until DataProvider mounts + * + * A fetch can settle before its DataProvider commits, when something outside it + * suspends or when the provider sits in a lazy layout (as with Expo Router). React + * discards that render's store, so the result would land nowhere. This holds + * results until init() and replays them into the store that mounted. + * + * Place it before NetworkManager, and share it with the managers it protects. + * @see https://dataclient.io/docs/api/CommitGateManager + */ +declare class CommitGateManager implements Manager { + protected held: [ActionTypes, () => void][]; + protected controller?: Controller; + protected mounted?: Controller; + middleware: Middleware; + init(): void; + cleanup(): void; + protected replay(): void; + protected drop(): void; +} + declare function BackupLoading(): React__default.JSX.Element; type DevToolsPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; @@ -303,4 +324,4 @@ type ReducerAction> = R extends React /** Turns a dispatch function into one that resolves once its been commited */ declare function usePromisifiedDispatch>(dispatch: React__default.Dispatch>, state: React__default.ReducerState): (action: ReducerAction) => Promise; -export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, ControllerContext, DataProvider, type DevToolsPosition, ErrorBoundary, WebIdlingNetworkManager as IdlingNetworkManager, ErrorBoundary as NetworkErrorBoundary, type ProviderProps, StateContext, type Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useCancelling, useController, useDLE, useDebounce, useError, useFetch, useLive, useLoading, usePromisifiedDispatch, useQuery, useSubscription, useSuspense }; +export { _default as AsyncBoundary, BackupLoading, DataProvider as CacheProvider, CommitGateManager, ControllerContext, DataProvider, type DevToolsPosition, ErrorBoundary, WebIdlingNetworkManager as IdlingNetworkManager, ErrorBoundary as NetworkErrorBoundary, type ProviderProps, StateContext, type Store, StoreContext, UniversalSuspense, internal_d as __INTERNAL__, getDefaultManagers, useCache, useCancelling, useController, useDLE, useDebounce, useError, useFetch, useLive, useLoading, usePromisifiedDispatch, useQuery, useSubscription, useSuspense }; From 06fd1977d832c8119c52aac4075ae0e0521616d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:34:47 +0000 Subject: [PATCH 26/38] fix(react): Drop held results on any RESET and reject failed replays in CommitGateManager Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- docs/core/api/CommitGateManager.md | 7 +++++ .../react/src/managers/CommitGateManager.ts | 26 ++++++++++++------- .../__tests__/CommitGateManager.web.ts | 22 ++++++++++++++++ .../editor-types/@data-client/react.d.ts | 8 ++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/docs/core/api/CommitGateManager.md b/docs/core/api/CommitGateManager.md index d8fd84869589..0351ec98f3d8 100644 --- a/docs/core/api/CommitGateManager.md +++ b/docs/core/api/CommitGateManager.md @@ -40,6 +40,13 @@ const managers = [new CommitGateManager(), ...getDefaultManagers()]; +:::note + +Put it first, in the same `managers` list as the [NetworkManager](./NetworkManager.md) it protects, +and give each `DataProvider` its own `CommitGateManager`. + +::: + :::tip Placing `DataProvider` at the root, with every [Suspense](https://react.dev/reference/react/Suspense) diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index cbb2d40464ed..9f613266a238 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -13,23 +13,31 @@ import type { * discards that render's store, so the result would land nowhere. This holds * results until init() and replays them into the store that mounted. * - * Place it before NetworkManager, and share it with the managers it protects. + * Place it first in the managers it protects, with one gate per DataProvider. * @see https://dataclient.io/docs/api/CommitGateManager */ export default class CommitGateManager implements Manager { - protected held: [ActionTypes, () => void][] = []; + protected held: [ + action: ActionTypes, + resolve: () => void, + reject: (error: unknown) => void, + ][] = []; protected controller?: Controller; protected mounted?: Controller; middleware: Middleware = controller => { this.controller = controller; return next => action => { - if (controller === this.mounted) { - if (action.type === actionTypes.RESET) this.drop(); + // held results predate any reset, whichever store it reaches + if (action.type === actionTypes.RESET) this.drop(); + if ( + controller === this.mounted || + action.type !== actionTypes.SET_RESPONSE + ) return next(action); - } - if (action.type !== actionTypes.SET_RESPONSE) return next(action); - return new Promise(resolve => this.held.push([action, resolve])); + return new Promise((resolve, reject) => + this.held.push([action, resolve, reject]), + ); }; }; @@ -49,8 +57,8 @@ export default class CommitGateManager implements Manager { if (!mounted) return; const held = this.held; this.held = []; - for (const [action, resolve] of held) - mounted.dispatch(action).then(resolve); + for (const [action, resolve, reject] of held) + mounted.dispatch(action).then(resolve, reject); } protected drop() { diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 02858945f53c..0fa003db4f08 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -79,4 +79,26 @@ describe('CommitGateManager', () => { type: 'rdc/reset', }); }); + + it('drops held results on a RESET before mount', async () => { + const gate = new CommitGateManager(); + const { controller, reduced, mount } = bind(gate); + const held = controller.dispatch(setResponse()); + void controller.dispatch(actions.createReset()); + mount(); + await held; + expect(reduced).toHaveBeenCalledTimes(1); + expect(reduced.mock.calls[0][0]).toMatchObject({ type: 'rdc/reset' }); + }); + + it('rejects the held promise when its replay fails', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const held = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + const failure = new Error('replay failed'); + committed.reduced.mockImplementationOnce(() => Promise.reject(failure)); + committed.mount(); + await expect(held).rejects.toBe(failure); + }); }); diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index e599cb9def8a..c51bc2d30a9c 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -15,11 +15,15 @@ declare class WebIdlingNetworkManager extends NetworkManager { * discards that render's store, so the result would land nowhere. This holds * results until init() and replays them into the store that mounted. * - * Place it before NetworkManager, and share it with the managers it protects. + * Place it first in the managers it protects, with one gate per DataProvider. * @see https://dataclient.io/docs/api/CommitGateManager */ declare class CommitGateManager implements Manager { - protected held: [ActionTypes, () => void][]; + protected held: [ + action: ActionTypes, + resolve: () => void, + reject: (error: unknown) => void + ][]; protected controller?: Controller; protected mounted?: Controller; middleware: Middleware; From 25b916aa445a462d8930042c575721586d0c57eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:39:27 +0000 Subject: [PATCH 27/38] fix(react): Keep replaying held results when one replay throws Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- packages/react/src/managers/CommitGateManager.ts | 10 ++++++++-- .../managers/__tests__/CommitGateManager.web.ts | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index 9f613266a238..80d5ac578470 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -57,8 +57,14 @@ export default class CommitGateManager implements Manager { if (!mounted) return; const held = this.held; this.held = []; - for (const [action, resolve, reject] of held) - mounted.dispatch(action).then(resolve, reject); + for (const [action, resolve, reject] of held) { + // one failing middleware must not strand the rest + try { + mounted.dispatch(action).then(resolve, reject); + } catch (error) { + reject(error); + } + } } protected drop() { diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 0fa003db4f08..2ef2dd7f0233 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -101,4 +101,20 @@ describe('CommitGateManager', () => { committed.mount(); await expect(held).rejects.toBe(failure); }); + + it('replays the rest when one replay throws', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const first = discarded.controller.dispatch(setResponse()); + const second = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + const failure = new Error('middleware threw'); + committed.reduced.mockImplementationOnce(() => { + throw failure; + }); + committed.mount(); + await expect(first).rejects.toBe(failure); + await second; + expect(committed.reduced).toHaveBeenCalledTimes(2); + }); }); From d73110759359dbade4bd34bbee5686a9bf0fd5b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:43:10 +0000 Subject: [PATCH 28/38] internal(react): Use top-level type imports in the precommit race suite Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../src/components/__tests__/precommit-race.native.tsx | 6 ++---- .../components/__tests__/precommit-race.node-suite.tsx | 9 +++------ .../react/src/components/__tests__/precommit-race.tsx | 7 ++----- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.native.tsx b/packages/react/src/components/__tests__/precommit-race.native.tsx index 647d62431a70..44642009fe68 100644 --- a/packages/react/src/components/__tests__/precommit-race.native.tsx +++ b/packages/react/src/components/__tests__/precommit-race.native.tsx @@ -2,10 +2,8 @@ import type { ReactNode } from 'react'; import { Text } from 'react-native'; import TestRenderer from 'react-test-renderer'; -import { - registerPrecommitRaceTests, - type RaceHost, -} from './precommit-race.node-suite'; +import { registerPrecommitRaceTests } from './precommit-race.node-suite'; +import type { RaceHost } from './precommit-race.node-suite'; const host: RaceHost = { Text: ({ children }: { children?: ReactNode }) => {children}, diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 47d8b27d4be7..542643763319 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -12,11 +12,8 @@ * is suspended, so the race cannot happen there. `use()` exists from 19. */ import { Endpoint } from '@data-client/endpoint'; -import { - makeGate, - recordConsoleErrors, - type GatePromise, -} from '__tests__/streamingHarness'; +import { makeGate, recordConsoleErrors } from '__tests__/streamingHarness'; +import type { GatePromise } from '__tests__/streamingHarness'; import { use, lazy, @@ -29,9 +26,9 @@ import { import type { ReactElement, ReactNode } from 'react'; import { useFetch, useSuspense } from '../../hooks'; +import { CommitGateManager } from '../../managers'; import DataProvider from '../DataProvider'; import ErrorBoundary from '../ErrorBoundary'; -import { CommitGateManager } from '../../managers'; import { getDefaultManagers } from '../getDefaultManagers'; import { LegacyReact } from '../LegacyReact'; diff --git a/packages/react/src/components/__tests__/precommit-race.tsx b/packages/react/src/components/__tests__/precommit-race.tsx index 6415ba4a8094..eb491ebab48c 100644 --- a/packages/react/src/components/__tests__/precommit-race.tsx +++ b/packages/react/src/components/__tests__/precommit-race.tsx @@ -1,11 +1,8 @@ import type { ReactElement, ReactNode } from 'react'; import { LegacyReact } from '../LegacyReact'; -import { - registerPrecommitRaceTests, - type RaceHost, - type RaceRenderer, -} from './precommit-race.node-suite'; +import { registerPrecommitRaceTests } from './precommit-race.node-suite'; +import type { RaceHost, RaceRenderer } from './precommit-race.node-suite'; function createRoot(container: HTMLElement): Omit { if (LegacyReact) { From c722d0aa9db3753044b4631c6551e9a7206fe83f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:06:21 +0000 Subject: [PATCH 29/38] fix(react): Add CommitGateManager in DataProvider instead of native getDefaultManagers External stores built from getDefaultManagers() never call initManager(), so a gate there held every result forever. DataProvider now prepends the gate on React Native, reusing one gate per managers list so discarded renders share it, and always ahead of custom managers so none run twice on replay. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../references/getDefaultManagers.md | 2 -- .../references/DataProvider.md | 2 ++ .../references/DataProvider.md | 2 ++ .../references/getDefaultManagers.md | 2 -- .changeset/precommit-fetch.md | 2 +- docs/core/api/CommitGateManager.md | 2 +- docs/core/api/DataProvider.md | 2 ++ docs/core/api/getDefaultManagers.md | 6 ------ packages/react/src/components/DataProvider.tsx | 6 ++++-- .../__tests__/precommit-race.native.tsx | 1 + .../__tests__/precommit-race.node-suite.tsx | 13 ++++++++----- .../react/src/components/getDefaultManagers.tsx | 17 +++++++---------- .../src/components/platformManagers.native.ts | 12 +++++++++++- .../react/src/components/platformManagers.ts | 2 +- 14 files changed, 40 insertions(+), 31 deletions(-) diff --git a/.agents/skills/data-client-manager/references/getDefaultManagers.md b/.agents/skills/data-client-manager/references/getDefaultManagers.md index e739852feb48..f62eacd3a70a 100644 --- a/.agents/skills/data-client-manager/references/getDefaultManagers.md +++ b/.agents/skills/data-client-manager/references/getDefaultManagers.md @@ -11,8 +11,6 @@ Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsMan \*(`DevToolsManager` is excluded in production builds.) -On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) comes first. - ## Usage **Web** diff --git a/.agents/skills/data-client-react/references/DataProvider.md b/.agents/skills/data-client-react/references/DataProvider.md index 0b11db5c5e63..bc30e5308633 100644 --- a/.agents/skills/data-client-react/references/DataProvider.md +++ b/.agents/skills/data-client-react/references/DataProvider.md @@ -148,6 +148,8 @@ List of [Manager](https://dataclient.io/docs/api/Manager)s use. This is the main [getDefaultManagers()](https://dataclient.io/docs/api/getDefaultManagers) can be used to extend the default managers. +On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) is added first unless `managers` already includes it. + Default Production: ```typescript diff --git a/.agents/skills/data-client-setup/references/DataProvider.md b/.agents/skills/data-client-setup/references/DataProvider.md index a95143d87f73..3c9c480b3e4d 100644 --- a/.agents/skills/data-client-setup/references/DataProvider.md +++ b/.agents/skills/data-client-setup/references/DataProvider.md @@ -148,6 +148,8 @@ List of [Manager](https://dataclient.io/docs/api/Manager)s use. This is the main [getDefaultManagers()](./getDefaultManagers.md) can be used to extend the default managers. +On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) is added first unless `managers` already includes it. + Default Production: ```typescript diff --git a/.agents/skills/data-client-setup/references/getDefaultManagers.md b/.agents/skills/data-client-setup/references/getDefaultManagers.md index c63cd76abbbb..3f95643a3342 100644 --- a/.agents/skills/data-client-setup/references/getDefaultManagers.md +++ b/.agents/skills/data-client-setup/references/getDefaultManagers.md @@ -11,8 +11,6 @@ Currently returns \[[DevToolsManager](https://dataclient.io/docs/api/DevToolsMan \*(`DevToolsManager` is excluded in production builds.) -On React Native, [CommitGateManager](https://dataclient.io/docs/api/CommitGateManager) comes first. - ## Usage **Web** diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md index 8e1432fb166d..4d248db3fb04 100644 --- a/.changeset/precommit-fetch.md +++ b/.changeset/precommit-fetch.md @@ -6,7 +6,7 @@ Fix Suspense staying on the fallback when a fetch resolves before DataProvider m [useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. This happens when the provider sits inside a lazy-loaded layout, as with Expo Router, or when something outside the provider suspends during the first render. -React Native's default managers now include `CommitGateManager`, which holds those results until the provider mounts. On web, add it before the other managers when a provider can render inside a lazy layout or next to a suspending sibling: +On React Native, DataProvider now adds `CommitGateManager` first to its managers, which holds those results until the provider mounts. On web, add it before the other managers when a provider can render inside a lazy layout or next to a suspending sibling: ```tsx diff --git a/docs/core/api/CommitGateManager.md b/docs/core/api/CommitGateManager.md index 0351ec98f3d8..513d58798597 100644 --- a/docs/core/api/CommitGateManager.md +++ b/docs/core/api/CommitGateManager.md @@ -25,7 +25,7 @@ that mounts, so the endpoint is called only once. ## Usage -React Native's [getDefaultManagers()](./getDefaultManagers.md) already includes it, since +On React Native, [DataProvider](./DataProvider.md#managers) adds it first to its `managers`, since [Expo Router](https://docs.expo.dev/router/introduction/) loads the root layout lazily. On web, add it first when `DataProvider` renders inside a [lazy](https://react.dev/reference/react/lazy) diff --git a/docs/core/api/DataProvider.md b/docs/core/api/DataProvider.md index fda1fdf7dbe7..52329393e180 100644 --- a/docs/core/api/DataProvider.md +++ b/docs/core/api/DataProvider.md @@ -51,6 +51,8 @@ List of [Manager](./Manager.md)s use. This is the main extensibility point of th [getDefaultManagers()](./getDefaultManagers.md) can be used to extend the default managers. +On React Native, [CommitGateManager](./CommitGateManager.md) is added first unless `managers` already includes it. + Default Production: ```typescript diff --git a/docs/core/api/getDefaultManagers.md b/docs/core/api/getDefaultManagers.md index d97c6f29ab1c..fd5540a41189 100644 --- a/docs/core/api/getDefaultManagers.md +++ b/docs/core/api/getDefaultManagers.md @@ -18,12 +18,6 @@ Currently returns \[[DevToolsManager](./DevToolsManager.md)\*, [NetworkManager]( \*(`DevToolsManager` is excluded in production builds.) -:::react - -On React Native, [CommitGateManager](./CommitGateManager.md) comes first. - -::: - ## Usage diff --git a/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx index 1edd8900aaa6..4b9e9925ba9c 100644 --- a/packages/react/src/components/DataProvider.tsx +++ b/packages/react/src/components/DataProvider.tsx @@ -12,6 +12,7 @@ import type { JSX } from 'react'; import DataStore from './DataStore.js'; import type { DevToolsPosition } from './DevToolsButton.js'; import { getDefaultManagers } from './getDefaultManagers.js'; +import platformManagers from './platformManagers.js'; import { SSR } from './LegacyReact.js'; import { renderDevButton } from './renderDevButton.js'; import { ControllerContext } from '../context.js'; @@ -55,8 +56,9 @@ See https://dataclient.io/docs/guides/ssr.`, controllerRef.current = new Controller({ gcPolicy: gcRef.current }); //TODO: bind all methods so destructuring works - const managersRef: React.RefObject = useRef(managers); - if (!managersRef.current) managersRef.current = getDefaultManagers(); + const managersRef: React.RefObject = useRef(undefined); + if (!managersRef.current) + managersRef.current = platformManagers(managers ?? getDefaultManagers()); // run in a useEffect in DataStore // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/react/src/components/__tests__/precommit-race.native.tsx b/packages/react/src/components/__tests__/precommit-race.native.tsx index 44642009fe68..8769b1aba3c0 100644 --- a/packages/react/src/components/__tests__/precommit-race.native.tsx +++ b/packages/react/src/components/__tests__/precommit-race.native.tsx @@ -6,6 +6,7 @@ import { registerPrecommitRaceTests } from './precommit-race.node-suite'; import type { RaceHost } from './precommit-race.node-suite'; const host: RaceHost = { + addsGate: true, Text: ({ children }: { children?: ReactNode }) => {children}, createRenderer() { let renderer: TestRenderer.ReactTestRenderer | undefined; diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 542643763319..f60178b68f1f 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -41,8 +41,12 @@ export interface RaceRenderer { export interface RaceHost { createRenderer(): RaceRenderer; Text: (props: { children?: ReactNode }) => ReactElement; + /** DataProvider adds CommitGateManager itself, as on React Native */ + addsGate?: boolean; } +let addsGate = false; + const CALL_CAP = 30; const POLL_MS = 10; // three waits must fit inside Jest's default 5s test timeout @@ -111,12 +115,10 @@ function FetchReader({ return {`value ${value}`}; } -// web opts in; native's defaults already include the gate +// web opts in; on native DataProvider adds the gate itself function gatedManagers() { const managers = getDefaultManagers(); - return managers.some(m => m instanceof CommitGateManager) ? managers : ( - [new CommitGateManager(), ...managers] - ); + return addsGate ? managers : [new CommitGateManager(), ...managers]; } // like DataProvider's own defaults: every render that is thrown away builds new managers @@ -162,7 +164,7 @@ async function runRace( const gate = makeGate(); const managers = shared ? gatedManagers() : undefined; const Provider = - managers ? + managers || addsGate ? ({ children }: { children: ReactNode }) => ( {children} @@ -310,6 +312,7 @@ function expectResolved( } export function registerPrecommitRaceTests(host: RaceHost) { + addsGate = !!host.addsGate; describe('fetch that settles before DataProvider commits', () => { const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; let prevAct: boolean | undefined; diff --git a/packages/react/src/components/getDefaultManagers.tsx b/packages/react/src/components/getDefaultManagers.tsx index 36cdba541551..980be74c3efb 100644 --- a/packages/react/src/components/getDefaultManagers.tsx +++ b/packages/react/src/components/getDefaultManagers.tsx @@ -6,7 +6,6 @@ import { PollingSubscription, DevToolsManager, } from '../managers/index.js'; -import platformManagers from './platformManagers.js'; /* istanbul ignore next */ /** Returns the default Managers used by DataProvider. @@ -21,14 +20,12 @@ let getDefaultManagers: ({ networkManager, subscriptionManager = PollingSubscription, } = {}) => - platformManagers( - subscriptionManager === null ? - [constructManager(NetworkManager, networkManager)] - : [ - constructManager(NetworkManager, networkManager), - constructManager(SubscriptionManager, subscriptionManager), - ], - ); + subscriptionManager === null ? + [constructManager(NetworkManager, networkManager)] + : [ + constructManager(NetworkManager, networkManager), + constructManager(SubscriptionManager, subscriptionManager), + ]; /* istanbul ignore else */ if (process.env.NODE_ENV !== 'production') { getDefaultManagers = ({ @@ -52,7 +49,7 @@ if (process.env.NODE_ENV !== 'production') { ), ); } - return platformManagers(managers); + return managers; }; } export { getDefaultManagers }; diff --git a/packages/react/src/components/platformManagers.native.ts b/packages/react/src/components/platformManagers.native.ts index 649073e2d51a..ad710087a77e 100644 --- a/packages/react/src/components/platformManagers.native.ts +++ b/packages/react/src/components/platformManagers.native.ts @@ -2,7 +2,17 @@ import type { Manager } from '@data-client/core'; import CommitGateManager from '../managers/CommitGateManager.js'; +// a render React discards must share its gate with the render that commits +const gated = new WeakMap(); + /** Expo Router loads the root layout lazily, so fetches can settle before DataProvider mounts */ export default function platformManagers(managers: Manager[]): Manager[] { - return [new CommitGateManager(), ...managers]; + if (managers.some(manager => manager instanceof CommitGateManager)) + return managers; + let withGate = gated.get(managers); + if (!withGate) { + withGate = [new CommitGateManager(), ...managers]; + gated.set(managers, withGate); + } + return withGate; } diff --git a/packages/react/src/components/platformManagers.ts b/packages/react/src/components/platformManagers.ts index df77ca926b40..1729510983d7 100644 --- a/packages/react/src/components/platformManagers.ts +++ b/packages/react/src/components/platformManagers.ts @@ -1,6 +1,6 @@ import type { Manager } from '@data-client/core'; -/** Web needs no platform managers; see platformManagers.native.ts */ +/** Managers DataProvider adds on this platform; see platformManagers.native.ts */ export default function platformManagers(managers: Manager[]): Manager[] { return managers; } From 502f486be97c6d8b617a0894e1ca5f33ed387045 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:18:28 +0000 Subject: [PATCH 30/38] Simplify CommitGateManager replay and race suite Settle held results by following the replayed dispatch, so a throwing middleware rejects only its own entry without a try/catch. In the race suite, pass the host instead of module state, merge the two provider shapes, share the reader tree and warning check, and declare RaceSpec once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../__tests__/precommit-race.node-suite.tsx | 154 ++++++++---------- .../react/src/managers/CommitGateManager.ts | 19 +-- 2 files changed, 76 insertions(+), 97 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index f60178b68f1f..a0c68ed7a61e 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -45,8 +45,6 @@ export interface RaceHost { addsGate?: boolean; } -let addsGate = false; - const CALL_CAP = 30; const POLL_MS = 10; // three waits must fit inside Jest's default 5s test timeout @@ -75,7 +73,7 @@ async function waitUntilStable(read: () => number, polls = 5) { } } -function makeEndpoint(settle: 'value' | 'error') { +function makeEndpoint(settle: Settle) { let calls = 0; const endpoint = new Endpoint( () => { @@ -89,7 +87,7 @@ function makeEndpoint(settle: 'value' | 'error') { return { endpoint, getCalls: () => calls }; } -function expectedText(settle: 'value' | 'error') { +function expectedText(settle: Settle) { return settle === 'error' ? 'error nope' : 'value 5'; } @@ -116,21 +114,12 @@ function FetchReader({ } // web opts in; on native DataProvider adds the gate itself -function gatedManagers() { +function gatedManagers(host: RaceHost) { const managers = getDefaultManagers(); - return addsGate ? managers : [new CommitGateManager(), ...managers]; -} - -// like DataProvider's own defaults: every render that is thrown away builds new managers -function FreshDataProvider({ children }: { children: ReactNode }) { - const [managers] = useState(gatedManagers); - return ( - - {children} - - ); + return host.addsGate ? managers : [new CommitGateManager(), ...managers]; } +// suspends through use() when available, like the sibling use() this race starts from function Blocker({ gate }: { gate: GatePromise }) { if (use) use(gate); else if (!gate.done) throw gate; @@ -140,53 +129,70 @@ function Blocker({ gate }: { gate: GatePromise }) { // 'lazy': DataProvider is inside a React.lazy layout, like Expo Router's root layout type Where = 'outside' | 'inside' | 'none' | 'lazy'; type ReaderKind = 'suspense' | 'fetch'; +type Settle = 'value' | 'error'; +interface RaceSpec { + shared: boolean; + where: Where; + transition: boolean; + reader: ReaderKind; + settle: Settle; + strict?: boolean; +} + +function readerTree( + host: RaceHost, + endpoint: ReturnType['endpoint'], + Reader: typeof SuspenseReader, + sibling: ReactNode = null, +) { + const Text = host.Text; + return ( + {`error ${error.message}`}} + > + fallback}> + + + {sibling} + + ); +} + +function warnedNotMounted(errors: ReturnType) { + return errors.unexpected.some(args => + args.join(' ').includes("hasn't mounted yet"), + ); +} async function runRace( host: RaceHost, - { - shared, - where, - transition, - reader, - settle, - strict = false, - }: { - shared: boolean; - where: Where; - transition: boolean; - reader: ReaderKind; - settle: 'value' | 'error'; - strict?: boolean; - }, + { shared, where, transition, reader, settle, strict = false }: RaceSpec, ) { const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); const gate = makeGate(); - const managers = shared ? gatedManagers() : undefined; - const Provider = - managers || addsGate ? - ({ children }: { children: ReactNode }) => ( - - {children} - - ) - : FreshDataProvider; + const sharedManagers = shared ? gatedManagers(host) : undefined; + // unshared: like DataProvider's own defaults, every render that is thrown away builds new managers + const initManagers = () => + sharedManagers ?? (host.addsGate ? undefined : gatedManagers(host)); + function Provider({ children }: { children: ReactNode }) { + const [managers] = useState(initManagers); + return ( + + {children} + + ); + } const Text = host.Text; const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; const provider = ( - ( - {`error ${error.message}`} - )} - > - fallback}> - - - {where === 'inside' ? - - : null} - + {readerTree( + host, + endpoint, + Reader, + where === 'inside' ? : null, + )} ); let tree: ReactElement; @@ -226,10 +232,12 @@ async function runRace( gate.release(); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); - const warned = errors.unexpected.some(args => - args.join(' ').includes("hasn't mounted yet"), - ); - return { beforeRelease, text: renderer.read(), calls: getCalls(), warned }; + return { + beforeRelease, + text: renderer.read(), + calls: getCalls(), + warned: warnedNotMounted(errors), + }; } finally { renderer.unmount(); errors.restore(); @@ -237,23 +245,14 @@ async function runRace( } // a hidden Activity renders its tree without mounting Effects, so DataProvider stays uncommitted until shown -async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { +async function runHiddenActivity(host: RaceHost, settle: Settle) { const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); - const managers = gatedManagers(); - const Text = host.Text; + const managers = gatedManagers(host); const tree = (mode: 'hidden' | 'visible') => ( - ( - {`error ${error.message}`} - )} - > - fallback}> - - - + {readerTree(host, endpoint, SuspenseReader)} ); @@ -268,15 +267,12 @@ async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { renderer.render(tree('visible')); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); - const warned = errors.unexpected.some(args => - args.join(' ').includes("hasn't mounted yet"), - ); return { beforeReveal, callsBeforeReveal, text: renderer.read(), calls: getCalls(), - warned, + warned: warnedNotMounted(errors), }; } finally { renderer.unmount(); @@ -286,11 +282,7 @@ async function runHiddenActivity(host: RaceHost, settle: 'value' | 'error') { function expectResolved( result: Awaited>, - { - shared, - where, - settle, - }: { shared: boolean; where: Where; settle: 'value' | 'error' }, + { shared, where, settle }: Pick, ) { const expected = expectedText(settle); if (where === 'lazy') { @@ -312,7 +304,6 @@ function expectResolved( } export function registerPrecommitRaceTests(host: RaceHost) { - addsGate = !!host.addsGate; describe('fetch that settles before DataProvider commits', () => { const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }; let prevAct: boolean | undefined; @@ -326,12 +317,7 @@ export function registerPrecommitRaceTests(host: RaceHost) { g.IS_REACT_ACT_ENVIRONMENT = prevAct; }); - const base: { - shared: boolean; - where: Where; - transition: boolean; - reader: ReaderKind; - } = { + const base: Omit = { shared: true, where: 'outside', transition: false, diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index 80d5ac578470..6528c384ae13 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -19,8 +19,7 @@ import type { export default class CommitGateManager implements Manager { protected held: [ action: ActionTypes, - resolve: () => void, - reject: (error: unknown) => void, + settle: (result?: Promise) => void, ][] = []; protected controller?: Controller; protected mounted?: Controller; @@ -35,9 +34,7 @@ export default class CommitGateManager implements Manager { action.type !== actionTypes.SET_RESPONSE ) return next(action); - return new Promise((resolve, reject) => - this.held.push([action, resolve, reject]), - ); + return new Promise(settle => this.held.push([action, settle])); }; }; @@ -57,18 +54,14 @@ export default class CommitGateManager implements Manager { if (!mounted) return; const held = this.held; this.held = []; - for (const [action, resolve, reject] of held) { - // one failing middleware must not strand the rest - try { - mounted.dispatch(action).then(resolve, reject); - } catch (error) { - reject(error); - } + for (const [action, settle] of held) { + // a throwing middleware rejects only its own result, not the rest + settle(new Promise(resolve => resolve(mounted.dispatch(action)))); } } protected drop() { - for (const [, resolve] of this.held) resolve(); + for (const [, settle] of this.held) settle(); this.held = []; } } From cac54f450864a5bb103b3ab912c0c6f4b21b9de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:21:20 +0000 Subject: [PATCH 31/38] Regenerate playground editor types Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../components/Playground/editor-types/@data-client/react.d.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index b68a0e314a44..85bd1877bbf4 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -21,8 +21,7 @@ declare class WebIdlingNetworkManager extends NetworkManager { declare class CommitGateManager implements Manager { protected held: [ action: ActionTypes, - resolve: () => void, - reject: (error: unknown) => void + settle: (result?: Promise) => void ][]; protected controller?: Controller; protected mounted?: Controller; From 1df2fdf2a1619a8449d6299eb86402f425f6fee2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:29:04 +0000 Subject: [PATCH 32/38] docs(blog): Add CommitGateManager to v0.19 release post Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- website/blog/2026-10-03-v0.19-batch-set.md | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index faf9ba3656f6..9f85402276fe 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -41,6 +41,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Fix `',' expected` errors importing `@data-client/react/redux`, `@data-client/react/nextjs` or `@data-client/test` on TypeScript 4.0–4.4 ([#4232](https://github.com/reactive/data-client/pull/4232)) - Fix `act` and `renderHook` from `@data-client/test` typed as `any` on TypeScript 4.0–4.4 ([#4251](https://github.com/reactive/data-client/pull/4251)) - React Native [useSuspense()](/docs/api/useSuspense), [useFetch()](/docs/api/useFetch) and [useDLE()](/docs/api/useDLE) refetch on screen focus only when the current data is stale or invalid, instead of refetching fresh data on every refocus ([#4378](https://github.com/reactive/data-client/pull/4378)) +- React Native [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` [no longer stay on the fallback](/blog/2026/10/03/v0.19-batch-set#commit-gate) when a fetch finishes before [DataProvider](/docs/api/DataProvider) mounts, as with Expo Router's lazy root layout; web apps can opt in with [CommitGateManager](/docs/api/CommitGateManager) ([#4097](https://github.com/reactive/data-client/pull/4097)) - [renderDataHook()](/docs/api/renderDataHook) works with React Native Testing Library 14 and stays synchronous, so `renderDataHook()` calls need no `await` ([#4374](https://github.com/reactive/data-client/pull/4374)) - [Entity](/rest/api/Entity) classes can be used where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)); [prepare `pk()` overrides](/blog/2026/10/03/v0.19-batch-set#entity-pk-args) for a future release - [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150)) @@ -317,6 +318,34 @@ On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.ext ## Other improvements +### Suspense before DataProvider mounts {#commit-gate} + +[useSuspense()](/docs/api/useSuspense) and `use(useFetch())` could stay on the `` fallback, or refetch in a +loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. React throws away a render +that doesn't commit, so the result had nowhere to land. This happens when the provider sits inside a lazy-loaded +layout, like Expo Router's root layout, or when something outside the provider suspends during the first render. + +On React Native, DataProvider now adds [CommitGateManager](/docs/api/CommitGateManager), which holds those results +until the provider mounts. Web apps that render the provider inside a lazy route or next to a suspending sibling can +add it before the other managers: + +```tsx +import { + CommitGateManager, + DataProvider, + getDefaultManagers, +} from '@data-client/react'; + +const managers = [new CommitGateManager(), ...getDefaultManagers()]; + + + +; +``` + +Placing DataProvider at the root, with every `` boundary inside it, also avoids the race. +[#4097](https://github.com/reactive/data-client/pull/4097) + ### Typed set() values {#typed-set} [Controller.set()](/docs/api/Controller#set) previously accepted any value for a schema, so a typo or a wrong From 596d6ea18a42a719f539c04395fab4bc45714261 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:39:22 +0000 Subject: [PATCH 33/38] docs(blog): Frame CommitGateManager entry as a React Native fix Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- website/blog/2026-10-03-v0.19-batch-set.md | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 9f85402276fe..608a94fc3f52 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -41,7 +41,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Fix `',' expected` errors importing `@data-client/react/redux`, `@data-client/react/nextjs` or `@data-client/test` on TypeScript 4.0–4.4 ([#4232](https://github.com/reactive/data-client/pull/4232)) - Fix `act` and `renderHook` from `@data-client/test` typed as `any` on TypeScript 4.0–4.4 ([#4251](https://github.com/reactive/data-client/pull/4251)) - React Native [useSuspense()](/docs/api/useSuspense), [useFetch()](/docs/api/useFetch) and [useDLE()](/docs/api/useDLE) refetch on screen focus only when the current data is stale or invalid, instead of refetching fresh data on every refocus ([#4378](https://github.com/reactive/data-client/pull/4378)) -- React Native [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` [no longer stay on the fallback](/blog/2026/10/03/v0.19-batch-set#commit-gate) when a fetch finishes before [DataProvider](/docs/api/DataProvider) mounts, as with Expo Router's lazy root layout; web apps can opt in with [CommitGateManager](/docs/api/CommitGateManager) ([#4097](https://github.com/reactive/data-client/pull/4097)) +- React Native [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` [no longer stay on the fallback](/blog/2026/10/03/v0.19-batch-set#commit-gate) when a fetch finishes before [DataProvider](/docs/api/DataProvider) mounts, as with Expo Router's lazy root layout ([#4097](https://github.com/reactive/data-client/pull/4097)) - [renderDataHook()](/docs/api/renderDataHook) works with React Native Testing Library 14 and stays synchronous, so `renderDataHook()` calls need no `await` ([#4374](https://github.com/reactive/data-client/pull/4374)) - [Entity](/rest/api/Entity) classes can be used where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)); [prepare `pk()` overrides](/blog/2026/10/03/v0.19-batch-set#entity-pk-args) for a future release - [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150)) @@ -318,16 +318,20 @@ On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.ext ## Other improvements -### Suspense before DataProvider mounts {#commit-gate} +### React Native Suspense before DataProvider mounts {#commit-gate} -[useSuspense()](/docs/api/useSuspense) and `use(useFetch())` could stay on the `` fallback, or refetch in a -loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. React throws away a render -that doesn't commit, so the result had nowhere to land. This happens when the provider sits inside a lazy-loaded -layout, like Expo Router's root layout, or when something outside the provider suspends during the first render. +On React Native, [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` could stay on the `` fallback, +or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. Expo Router +loads the root layout lazily, so the provider's first render can be thrown away, and a result that arrived during it +had nowhere to land. React Native's DataProvider now adds [CommitGateManager](/docs/api/CommitGateManager), which holds +those results until the provider mounts. No code changes are needed. +[#4097](https://github.com/reactive/data-client/pull/4097) + +:::tip Web -On React Native, DataProvider now adds [CommitGateManager](/docs/api/CommitGateManager), which holds those results -until the provider mounts. Web apps that render the provider inside a lazy route or next to a suspending sibling can -add it before the other managers: +Web apps aren't affected when DataProvider sits at the root with every `` boundary inside it, as recommended. +If yours renders the provider inside a lazy route or next to a component that suspends, add the gate before the other +managers: ```tsx import { @@ -343,8 +347,7 @@ const managers = [new CommitGateManager(), ...getDefaultManagers()]; ; ``` -Placing DataProvider at the root, with every `` boundary inside it, also avoids the race. -[#4097](https://github.com/reactive/data-client/pull/4097) +::: ### Typed set() values {#typed-set} From 9b82260f44703c53b85f6629ef07e6b45982a34e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:47:53 +0000 Subject: [PATCH 34/38] fix(react): Show a replay that throws as that fetch's error, and share the native gate across new managers arrays A Manager that throws while CommitGateManager replays a held result left the reader on its fallback with an unhandled rejection. Replay now publishes that throw as the fetch's error, as a mounted provider would. On React Native the gate was keyed on the managers array, so shared Manager instances passed in a new array each render got a new gate per render. It is now keyed on the NetworkManager instance. Adds race cases for both, plus a value set after mount. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../__tests__/precommit-race.node-suite.tsx | 111 ++++++++++++++++-- .../src/components/platformManagers.native.ts | 19 +-- .../react/src/managers/CommitGateManager.ts | 21 +++- .../__tests__/CommitGateManager.web.ts | 10 +- .../editor-types/@data-client/react.d.ts | 4 +- 5 files changed, 138 insertions(+), 27 deletions(-) diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index a0c68ed7a61e..0ecde5f3aa9f 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -11,6 +11,8 @@ * React 17 only has legacy roots, which commit DataProvider while a sibling * is suspended, so the race cannot happen there. `use()` exists from 19. */ +import { actionTypes } from '@data-client/core'; +import type { Controller, Manager, Middleware } from '@data-client/core'; import { Endpoint } from '@data-client/endpoint'; import { makeGate, recordConsoleErrors } from '__tests__/streamingHarness'; import type { GatePromise } from '__tests__/streamingHarness'; @@ -25,7 +27,7 @@ import { } from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { useFetch, useSuspense } from '../../hooks'; +import { useController, useFetch, useSuspense } from '../../hooks'; import { CommitGateManager } from '../../managers'; import DataProvider from '../DataProvider'; import ErrorBoundary from '../ErrorBoundary'; @@ -87,6 +89,19 @@ function makeEndpoint(settle: Settle) { return { endpoint, getCalls: () => calls }; } +// throws on the first result it sees, like a buggy custom Manager after NetworkManager +class ThrowOnceManager implements Manager { + protected thrown = false; + middleware: Middleware = () => next => action => { + if (action.type === actionTypes.SET_RESPONSE && !this.thrown) { + this.thrown = true; + throw new Error('middleware threw'); + } + return next(action); + }; + cleanup() {} +} + function expectedText(settle: Settle) { return settle === 'error' ? 'error nope' : 'value 5'; } @@ -114,11 +129,20 @@ function FetchReader({ } // web opts in; on native DataProvider adds the gate itself -function gatedManagers(host: RaceHost) { - const managers = getDefaultManagers(); +function gatedManagers(host: RaceHost, extra: Manager[] = []) { + const managers = [...getDefaultManagers(), ...extra]; return host.addsGate ? managers : [new CommitGateManager(), ...managers]; } +function ControllerProbe({ + onController, +}: { + onController: (c: Controller) => void; +}) { + onController(useController()); + return null; +} + // suspends through use() when available, like the sibling use() this race starts from function Blocker({ gate }: { gate: GatePromise }) { if (use) use(gate); @@ -137,6 +161,12 @@ interface RaceSpec { reader: ReaderKind; settle: Settle; strict?: boolean; + /** a Manager after NetworkManager throws on the first result */ + throwOnce?: boolean; + /** set a new value once mounted, which must reach the screen */ + mutate?: boolean; + /** shared Manager instances, but a new array on every render */ + freshArray?: boolean; } function readerTree( @@ -166,19 +196,35 @@ function warnedNotMounted(errors: ReturnType) { async function runRace( host: RaceHost, - { shared, where, transition, reader, settle, strict = false }: RaceSpec, + { + shared, + where, + transition, + reader, + settle, + strict = false, + throwOnce = false, + mutate = false, + freshArray = false, + }: RaceSpec, ) { const errors = recordConsoleErrors(); const { endpoint, getCalls } = makeEndpoint(settle); const gate = makeGate(); - const sharedManagers = shared ? gatedManagers(host) : undefined; + const extra = throwOnce ? [new ThrowOnceManager()] : []; + const sharedManagers = shared ? gatedManagers(host, extra) : undefined; // unshared: like DataProvider's own defaults, every render that is thrown away builds new managers const initManagers = () => - sharedManagers ?? (host.addsGate ? undefined : gatedManagers(host)); + sharedManagers ?? (host.addsGate ? undefined : gatedManagers(host, extra)); + let controller: Controller | undefined; function Provider({ children }: { children: ReactNode }) { const [managers] = useState(initManagers); return ( - + + (controller = c)} /> {children} ); @@ -219,7 +265,7 @@ async function runRace( outer}>{tree} : tree; const element = strict ? {root} : root; - const expected = expectedText(settle); + const expected = throwOnce ? 'error middleware threw' : expectedText(settle); const renderer = host.createRenderer(); try { if (transition) startTransition(() => renderer.render(element)); @@ -232,9 +278,17 @@ async function runRace( gate.release(); await waitUntil(() => renderer.read().includes(expected)); await waitUntilStable(getCalls); + const text = renderer.read(); + let afterMutate: string | undefined; + if (mutate) { + void controller?.setResponse(endpoint, 6); + await waitUntil(() => renderer.read().includes('value 6')); + afterMutate = renderer.read(); + } return { beforeRelease, - text: renderer.read(), + text, + afterMutate, calls: getCalls(), warned: warnedNotMounted(errors), }; @@ -350,6 +404,45 @@ export function registerPrecommitRaceTests(host: RaceHost) { ); } + it('shows the error when a Manager throws on the held result', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + let result; + try { + result = await runRace(host, { + ...base, + settle: 'value', + throwOnce: true, + }); + await tick(); + } finally { + process.off('unhandledRejection', onUnhandled); + } + expect(unhandled).toEqual([]); + // like a provider that mounted before the fetch settled + expect(result.text).toContain('error middleware threw'); + expect(result.text).not.toContain('fallback'); + expect(result.calls).toBe(1); + expect(result.warned).toBe(false); + }); + + it('applies updates made after mount', async () => { + const result = await runRace(host, { + ...base, + settle: 'value', + mutate: true, + }); + expect(result.text).toContain('value 5'); + expect(result.afterMutate).toContain('value 6'); + expect(result.calls).toBe(1); + }); + + it('holds results when shared Managers come in a new array each render', async () => { + const spec = { ...base, settle: 'value', freshArray: true } as const; + expectResolved(await runRace(host, spec), spec); + }); + it('shows a rejected fetch without calling the endpoint again', async () => { const spec = { ...base, settle: 'error' } as const; expectResolved(await runRace(host, spec), spec); diff --git a/packages/react/src/components/platformManagers.native.ts b/packages/react/src/components/platformManagers.native.ts index ad710087a77e..8f11a2c26bfb 100644 --- a/packages/react/src/components/platformManagers.native.ts +++ b/packages/react/src/components/platformManagers.native.ts @@ -1,18 +1,19 @@ +import { NetworkManager } from '@data-client/core'; import type { Manager } from '@data-client/core'; import CommitGateManager from '../managers/CommitGateManager.js'; -// a render React discards must share its gate with the render that commits -const gated = new WeakMap(); +// a render React discards must share its gate with the render that commits; +// keyed on the instance that owns the fetch, since the array can be new each render +const gates = new WeakMap(); -/** Expo Router loads the root layout lazily, so fetches can settle before DataProvider mounts */ +/** Fetches can settle before DataProvider mounts, as when something outside it suspends */ export default function platformManagers(managers: Manager[]): Manager[] { if (managers.some(manager => manager instanceof CommitGateManager)) return managers; - let withGate = gated.get(managers); - if (!withGate) { - withGate = [new CommitGateManager(), ...managers]; - gated.set(managers, withGate); - } - return withGate; + const owner = + managers.find(manager => manager instanceof NetworkManager) ?? managers[0]; + let gate = gates.get(owner); + if (!gate) gates.set(owner, (gate = new CommitGateManager())); + return [gate, ...managers]; } diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index 6528c384ae13..f7e4fd207082 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -1,9 +1,9 @@ import { actionTypes } from '@data-client/core'; import type { - ActionTypes, Controller, Manager, Middleware, + SetResponseAction, } from '@data-client/core'; /** Holds fetch results until DataProvider mounts @@ -18,7 +18,7 @@ import type { */ export default class CommitGateManager implements Manager { protected held: [ - action: ActionTypes, + action: SetResponseAction, settle: (result?: Promise) => void, ][] = []; protected controller?: Controller; @@ -55,8 +55,21 @@ export default class CommitGateManager implements Manager { const held = this.held; this.held = []; for (const [action, settle] of held) { - // a throwing middleware rejects only its own result, not the rest - settle(new Promise(resolve => resolve(mounted.dispatch(action)))); + try { + settle(mounted.dispatch(action)); + } catch (error) { + // like a dispatch that throws once mounted: that fetch shows the error, the rest still replay + settle( + action.error ? + Promise.reject(error) + : mounted.resolve(action.endpoint, { + args: action.args, + response: error as Error, + fetchedAt: action.meta.fetchedAt, + error: true, + }), + ); + } } } diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 2ef2dd7f0233..48110eccb324 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -102,7 +102,7 @@ describe('CommitGateManager', () => { await expect(held).rejects.toBe(failure); }); - it('replays the rest when one replay throws', async () => { + it('shows a replay that throws as that fetch error, and replays the rest', async () => { const gate = new CommitGateManager(); const discarded = bind(gate); const first = discarded.controller.dispatch(setResponse()); @@ -113,8 +113,12 @@ describe('CommitGateManager', () => { throw failure; }); committed.mount(); - await expect(first).rejects.toBe(failure); + await first; await second; - expect(committed.reduced).toHaveBeenCalledTimes(2); + const reduced = committed.reduced.mock.calls.map(([action]) => action); + // the call that threw, then its error, then the next result + expect(reduced).toHaveLength(3); + expect(reduced[1]).toMatchObject({ error: true, response: failure }); + expect(reduced[2]).toMatchObject({ error: false, response: 5 }); }); }); diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index 85bd1877bbf4..5a52ad5b3d58 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -1,5 +1,5 @@ import * as _data_client_core from '@data-client/core'; -import { NetworkManager, Manager, ActionTypes, Controller, Middleware, State, GCInterface, DevToolsManager, DevToolsConfig, SubscriptionManager, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, actions, applyManager, createReducer, initManager } from '@data-client/core'; +import { NetworkManager, Manager, SetResponseAction, Controller, Middleware, State, GCInterface, DevToolsManager, DevToolsConfig, SubscriptionManager, EndpointInterface, FetchFunction, Schema, ResolveType, Denormalize, DenormalizeNullable, Queryable, NI, SchemaArgs, NetworkError, UnknownError, ErrorTypes as ErrorTypes$1, __INTERNAL__, actions, applyManager, createReducer, initManager } from '@data-client/core'; export { AbstractInstanceType, ActionTypes, Controller, CreateCountRef, DataClientDispatch, DefaultConnectionListener, Denormalize, DenormalizeNullable, DevToolsManager, Dispatch, EndpointExtraOptions, EndpointInterface, EntityInterface, ErrorTypes, ExpiryStatus, FetchAction, FetchFunction, GCInterface, GCOptions, GCPolicy, GenericDispatch, InvalidateAction, LogoutManager, Manager, Middleware, MiddlewareAPI, NetworkError, NetworkManager, Normalize, NormalizeNullable, PK, PollingSubscription, Queryable, ResetAction, ResolveType, Schema, SchemaArgs, SchemaClass, SetAction, SetResponseAction, State, SubscribeAction, SubscriptionManager, UnknownError, UnsubscribeAction, UpdateFunction, actionTypes } from '@data-client/core'; import * as React from 'react'; import React__default, { JSX, Context } from 'react'; @@ -20,7 +20,7 @@ declare class WebIdlingNetworkManager extends NetworkManager { */ declare class CommitGateManager implements Manager { protected held: [ - action: ActionTypes, + action: SetResponseAction, settle: (result?: Promise) => void ][]; protected controller?: Controller; From 466b0a73cff316bf5426c4769984de35e6f89d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:51:55 +0000 Subject: [PATCH 35/38] test(react): Cover a held error result that throws on replay Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../__tests__/CommitGateManager.web.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 48110eccb324..1f038a30f036 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -121,4 +121,25 @@ describe('CommitGateManager', () => { expect(reduced[1]).toMatchObject({ error: true, response: failure }); expect(reduced[2]).toMatchObject({ error: false, response: 5 }); }); + + it('rejects when a held error result throws on replay', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const held = discarded.controller.dispatch( + actions.createSetResponse(endpoint, { + args: [], + response: new Error('fetch failed'), + fetchedAt: Date.now(), + error: true, + }), + ); + const committed = bind(gate); + const failure = new Error('middleware threw'); + committed.reduced.mockImplementationOnce(() => { + throw failure; + }); + committed.mount(); + await expect(held).rejects.toBe(failure); + expect(committed.reduced).toHaveBeenCalledTimes(1); + }); }); From 28c1b2b26b21dae3947e6047c4f6af0d29ea1fea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:53:10 +0000 Subject: [PATCH 36/38] fix(react): Never let a replay throw escape, and key the native gate on the array without a NetworkManager A throw while replaying a held error result is now swallowed, as NetworkManager does once mounted, instead of an unobserved rejection. A throw while publishing a replay's error no longer stops the rest of the replay. platformManagers falls back to the managers array as the gate key, so managers={[]} no longer throws on an undefined WeakMap key. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .../src/components/platformManagers.native.ts | 4 ++-- .../react/src/managers/CommitGateManager.ts | 23 +++++++++++------- .../__tests__/CommitGateManager.web.ts | 24 +++++++++++++++++-- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/packages/react/src/components/platformManagers.native.ts b/packages/react/src/components/platformManagers.native.ts index 8f11a2c26bfb..45a85ecd1fa7 100644 --- a/packages/react/src/components/platformManagers.native.ts +++ b/packages/react/src/components/platformManagers.native.ts @@ -5,14 +5,14 @@ import CommitGateManager from '../managers/CommitGateManager.js'; // a render React discards must share its gate with the render that commits; // keyed on the instance that owns the fetch, since the array can be new each render -const gates = new WeakMap(); +const gates = new WeakMap(); /** Fetches can settle before DataProvider mounts, as when something outside it suspends */ export default function platformManagers(managers: Manager[]): Manager[] { if (managers.some(manager => manager instanceof CommitGateManager)) return managers; const owner = - managers.find(manager => manager instanceof NetworkManager) ?? managers[0]; + managers.find(manager => manager instanceof NetworkManager) ?? managers; let gate = gates.get(owner); if (!gate) gates.set(owner, (gate = new CommitGateManager())); return [gate, ...managers]; diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index f7e4fd207082..623926e40b31 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -58,16 +58,21 @@ export default class CommitGateManager implements Manager { try { settle(mounted.dispatch(action)); } catch (error) { - // like a dispatch that throws once mounted: that fetch shows the error, the rest still replay + // like NetworkManager once mounted: a throw becomes that fetch's error, and a + // throw while publishing an error is swallowed; the rest still replay settle( - action.error ? - Promise.reject(error) - : mounted.resolve(action.endpoint, { - args: action.args, - response: error as Error, - fetchedAt: action.meta.fetchedAt, - error: true, - }), + action.error ? undefined : ( + new Promise(resolve => + resolve( + mounted.resolve(action.endpoint, { + args: action.args, + response: error as Error, + fetchedAt: action.meta.fetchedAt, + error: true, + }), + ), + ).catch(() => {}) + ), ); } } diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 1f038a30f036..3de6d4d1eb05 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -122,7 +122,7 @@ describe('CommitGateManager', () => { expect(reduced[2]).toMatchObject({ error: false, response: 5 }); }); - it('rejects when a held error result throws on replay', async () => { + it('swallows a throw while replaying a held error, like a mounted provider', async () => { const gate = new CommitGateManager(); const discarded = bind(gate); const held = discarded.controller.dispatch( @@ -139,7 +139,27 @@ describe('CommitGateManager', () => { throw failure; }); committed.mount(); - await expect(held).rejects.toBe(failure); + await expect(held).resolves.toBeUndefined(); expect(committed.reduced).toHaveBeenCalledTimes(1); }); + + it('keeps replaying when publishing the error also throws', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const first = discarded.controller.dispatch(setResponse()); + const second = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + committed.reduced + .mockImplementationOnce(() => { + throw new Error('middleware threw'); + }) + .mockImplementationOnce(() => { + throw new Error('error publish threw'); + }); + committed.mount(); + await first; + await second; + expect(committed.reduced).toHaveBeenCalledTimes(3); + expect(committed.reduced.mock.calls[2][0]).toMatchObject({ response: 5 }); + }); }); From 7c0767e4c9d0727755b4836f1f8f87464c71fb6f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 20:55:47 +0000 Subject: [PATCH 37/38] docs: Frame CommitGateManager around Expo Router's lazy layouts, not React Native Expo Router wraps the root layout in React.lazy when async routes are on, which is the default on web. That is what discards DataProvider's first render; it is not a React Native issue. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- .changeset/precommit-fetch.md | 8 ++-- docs/core/api/CommitGateManager.md | 9 +++-- .../react/src/managers/CommitGateManager.ts | 2 +- website/blog/2026-10-03-v0.19-batch-set.md | 39 ++++++++++--------- .../editor-types/@data-client/react.d.ts | 2 +- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md index 4d248db3fb04..4d5cb93db5f0 100644 --- a/.changeset/precommit-fetch.md +++ b/.changeset/precommit-fetch.md @@ -2,12 +2,14 @@ '@data-client/react': patch --- -Fix Suspense staying on the fallback when a fetch resolves before DataProvider mounts on React Native +Add `CommitGateManager` for DataProvider inside a lazy layout, such as Expo Router's root layout -[useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. This happens when the provider sits inside a lazy-loaded layout, as with Expo Router, or when something outside the provider suspends during the first render. +[useSuspense](/docs/api/useSuspense) and `use(useFetch())` could stay on the fallback, or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. Expo Router wraps routes, including the root `_layout.tsx`, in `React.lazy` when async routes are on (the default on web), so React can throw away the provider's first render. Any DataProvider inside `React.lazy`, or next to something that suspends outside it, is affected. This is not specific to React Native. -On React Native, DataProvider now adds `CommitGateManager` first to its managers, which holds those results until the provider mounts. On web, add it before the other managers when a provider can render inside a lazy layout or next to a suspending sibling: +Add `CommitGateManager` before the other managers. It holds those results until the provider mounts: ```tsx ``` + +On React Native, DataProvider adds it automatically. diff --git a/docs/core/api/CommitGateManager.md b/docs/core/api/CommitGateManager.md index 513d58798597..5dc361e84a11 100644 --- a/docs/core/api/CommitGateManager.md +++ b/docs/core/api/CommitGateManager.md @@ -25,11 +25,12 @@ that mounts, so the endpoint is called only once. ## Usage -On React Native, [DataProvider](./DataProvider.md#managers) adds it first to its `managers`, since -[Expo Router](https://docs.expo.dev/router/introduction/) loads the root layout lazily. +Add it first when `DataProvider` renders inside a [lazy](https://react.dev/reference/react/lazy) +layout or route, or next to a component that can suspend outside it. The most common case is +[Expo Router](https://docs.expo.dev/router/introduction/): with async routes on, which is the default +on web, it wraps the root `_layout.tsx` in `React.lazy`. This isn't specific to React Native. -On web, add it first when `DataProvider` renders inside a [lazy](https://react.dev/reference/react/lazy) -layout or route, or next to a component that can suspend outside it. +On React Native, [DataProvider](./DataProvider.md#managers) adds it first to its `managers` for you. diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index 623926e40b31..57dda3df5f59 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -9,7 +9,7 @@ import type { /** Holds fetch results until DataProvider mounts * * A fetch can settle before its DataProvider commits, when something outside it - * suspends or when the provider sits in a lazy layout (as with Expo Router). React + * suspends or when the provider sits in a lazy layout (as with Expo Router on web). React * discards that render's store, so the result would land nowhere. This holds * results until init() and replays them into the store that mounted. * diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 608a94fc3f52..931abef01919 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -41,7 +41,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Fix `',' expected` errors importing `@data-client/react/redux`, `@data-client/react/nextjs` or `@data-client/test` on TypeScript 4.0–4.4 ([#4232](https://github.com/reactive/data-client/pull/4232)) - Fix `act` and `renderHook` from `@data-client/test` typed as `any` on TypeScript 4.0–4.4 ([#4251](https://github.com/reactive/data-client/pull/4251)) - React Native [useSuspense()](/docs/api/useSuspense), [useFetch()](/docs/api/useFetch) and [useDLE()](/docs/api/useDLE) refetch on screen focus only when the current data is stale or invalid, instead of refetching fresh data on every refocus ([#4378](https://github.com/reactive/data-client/pull/4378)) -- React Native [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` [no longer stay on the fallback](/blog/2026/10/03/v0.19-batch-set#commit-gate) when a fetch finishes before [DataProvider](/docs/api/DataProvider) mounts, as with Expo Router's lazy root layout ([#4097](https://github.com/reactive/data-client/pull/4097)) +- [CommitGateManager](/blog/2026/10/03/v0.19-batch-set#commit-gate) keeps [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` off a stuck fallback when [DataProvider](/docs/api/DataProvider) sits in a layout Expo Router loads lazily, as it does on web ([#4097](https://github.com/reactive/data-client/pull/4097)) - [renderDataHook()](/docs/api/renderDataHook) works with React Native Testing Library 14 and stays synchronous, so `renderDataHook()` calls need no `await` ([#4374](https://github.com/reactive/data-client/pull/4374)) - [Entity](/rest/api/Entity) classes can be used where an `EntityInterface` is expected ([#4149](https://github.com/reactive/data-client/pull/4149)); [prepare `pk()` overrides](/blog/2026/10/03/v0.19-batch-set#entity-pk-args) for a future release - [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150)) @@ -318,22 +318,20 @@ On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.ext ## Other improvements -### React Native Suspense before DataProvider mounts {#commit-gate} +### Expo Router layouts and DataProvider {#commit-gate} -On React Native, [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` could stay on the `` fallback, -or refetch in a loop, when their fetch finished before [DataProvider](/docs/api/DataProvider) had mounted. Expo Router -loads the root layout lazily, so the provider's first render can be thrown away, and a result that arrived during it -had nowhere to land. React Native's DataProvider now adds [CommitGateManager](/docs/api/CommitGateManager), which holds -those results until the provider mounts. No code changes are needed. -[#4097](https://github.com/reactive/data-client/pull/4097) - -:::tip Web +[Expo Router](https://docs.expo.dev/router/introduction/) wraps routes in [React.lazy](https://react.dev/reference/react/lazy) +when async routes are on, which is the default on web. With [DataProvider](/docs/api/DataProvider) in the root +`_layout.tsx`, React can throw away the provider's first render. A fetch that finished in that window had nowhere to land, +so [useSuspense()](/docs/api/useSuspense) and `use(useFetch())` could stay on the `` fallback or refetch in a +loop. The same happens with any `DataProvider` rendered inside `React.lazy`. It isn't specific to React Native. -Web apps aren't affected when DataProvider sits at the root with every `` boundary inside it, as recommended. -If yours renders the provider inside a lazy route or next to a component that suspends, add the gate before the other -managers: +Add [CommitGateManager](/docs/api/CommitGateManager) before the other managers. It holds those results until the +provider mounts, and the endpoint is called only once. +[#4097](https://github.com/reactive/data-client/pull/4097) -```tsx +```tsx title="app/_layout.tsx" +import { Stack } from 'expo-router'; import { CommitGateManager, DataProvider, @@ -342,12 +340,17 @@ import { const managers = [new CommitGateManager(), ...getDefaultManagers()]; - - -; +export default function RootLayout() { + return ( + + + + ); +} ``` -::: +On React Native, DataProvider adds it for you, which covers apps that turn on async routes for native too. +Without a lazy layout, keep `DataProvider` at the root with every `` boundary inside it, and you won't need it. ### Typed set() values {#typed-set} diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index 5a52ad5b3d58..581a83c4594d 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -11,7 +11,7 @@ declare class WebIdlingNetworkManager extends NetworkManager { /** Holds fetch results until DataProvider mounts * * A fetch can settle before its DataProvider commits, when something outside it - * suspends or when the provider sits in a lazy layout (as with Expo Router). React + * suspends or when the provider sits in a lazy layout (as with Expo Router on web). React * discards that render's store, so the result would land nowhere. This holds * results until init() and replays them into the store that mounted. * From 953aabc4158ff2bb2f2c6aaebf8ce31406d79623 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:14:42 +0000 Subject: [PATCH 38/38] fix(react): CommitGateManager drops pre-reset results and stays open on the server Cases from the #4448 reviews, now covered for the gate: - A result fetched before a pre-mount resetEntireStore() that settles after it was held and replayed, since NetworkManager reads lastReset from a store the reset never reached. The gate now drops it. - During server rendering nothing mounts, so the gate passes actions through instead of holding them forever. - Race tests for a transition remount that never commits, a reset before mount, and a transition dispatched before mount. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01GSnmddZk6H7dfYTir6Sp3k --- docs/core/api/CommitGateManager.md | 3 +- .../__tests__/precommit-race.node-suite.tsx | 178 +++++++++++++++++- .../react/src/managers/CommitGateManager.ts | 13 +- .../__tests__/CommitGateManager.node.tsx | 31 +++ .../__tests__/CommitGateManager.web.ts | 20 ++ .../editor-types/@data-client/react.d.ts | 1 + 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 packages/react/src/managers/__tests__/CommitGateManager.node.tsx diff --git a/docs/core/api/CommitGateManager.md b/docs/core/api/CommitGateManager.md index 5dc361e84a11..8ddcb13e282e 100644 --- a/docs/core/api/CommitGateManager.md +++ b/docs/core/api/CommitGateManager.md @@ -62,7 +62,8 @@ boundary inside it, avoids the race without this manager. #### Processed Actions - [setResponse](./Controller.md#setResponse) is held until the provider mounts. -- [resetEntireStore](./Controller.md#resetEntireStore) drops held results. +- [resetEntireStore](./Controller.md#resetEntireStore) drops held results, and results fetched before it that settle later. +- During server rendering, nothing is held, since a server render never mounts. #### Dispatched Actions diff --git a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx index 0ecde5f3aa9f..bce9dff74388 100644 --- a/packages/react/src/components/__tests__/precommit-race.node-suite.tsx +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -27,7 +27,7 @@ import { } from 'react'; import type { ReactElement, ReactNode } from 'react'; -import { useController, useFetch, useSuspense } from '../../hooks'; +import { useCache, useController, useFetch, useSuspense } from '../../hooks'; import { CommitGateManager } from '../../managers'; import DataProvider from '../DataProvider'; import ErrorBoundary from '../ErrorBoundary'; @@ -334,6 +334,153 @@ async function runHiddenActivity(host: RaceHost, settle: Settle) { } } +function Forever(): ReactNode { + throw new Promise(() => {}); +} + +// a key change in a transition renders a new provider that never commits, next to the mounted one +async function runTransitionRemount(host: RaceHost) { + const errors = recordConsoleErrors(); + const { endpoint, getCalls } = makeEndpoint('value'); + const managers = gatedManagers(host); + const controllers: Controller[] = []; + const Text = host.Text; + const tree = (k: number) => ( + outer}> + + controllers.push(c)} /> + {readerTree(host, endpoint, SuspenseReader)} + + {k > 1 ? + + : null} + + ); + const renderer = host.createRenderer(); + try { + renderer.render(tree(1)); + await waitUntil(() => renderer.read().includes('value 5')); + const mounted = controllers[0]; + startTransition(() => renderer.render(tree(2))); + await waitUntil(() => controllers.some(c => c !== mounted)); + await tick(); + const duringTransition = renderer.read(); + let settled = false; + void mounted.setResponse(endpoint, 6).then(() => (settled = true)); + await waitUntil(() => renderer.read().includes('value 6')); + await waitUntil(() => settled); + return { + rendered: controllers.some(c => c !== mounted), + duringTransition, + text: renderer.read(), + settled, + calls: getCalls(), + warned: warnedNotMounted(errors), + }; + } finally { + renderer.unmount(); + errors.restore(); + } +} + +// before mount: a reset while a fetch is in flight, then that stale response arrives +async function runResetBeforeMount(host: RaceHost) { + const errors = recordConsoleErrors(); + let calls = 0; + let releaseStale = (_value: number) => {}; + const endpoint = new Endpoint( + () => { + calls += 1; + if (calls === 1) + return new Promise(resolve => (releaseStale = resolve)); + if (calls > CALL_CAP) return new Promise(() => {}); + return Promise.resolve(100); + }, + { dataExpiryLength: Infinity, name: 'precommit-reset' }, + ); + const gate = makeGate(); + const managers = gatedManagers(host); + let controller: Controller | undefined; + const tree = ( + <> + + (controller = c)} /> + {readerTree(host, endpoint, SuspenseReader)} + + + + ); + const element = + LegacyReact ? + outer}>{tree} + : tree; + const renderer = host.createRenderer(); + try { + renderer.render(element); + await waitUntil(() => calls > 0); + // the response must be older than the reset, as NetworkManager compares them by time + await tick(); + void controller?.resetEntireStore(); + releaseStale(9); + await tick(); + gate.release(); + const seen = new Set(); + await waitUntil(() => { + seen.add(renderer.read()); + return renderer.read().includes('value 100'); + }); + await waitUntilStable(() => calls); + return { + seen: [...seen], + text: renderer.read(), + calls, + warned: warnedNotMounted(errors), + }; + } finally { + renderer.unmount(); + errors.restore(); + } +} + +// before mount, a transition sets a value while something outside the provider suspends +async function runTransitionBeforeMount(host: RaceHost) { + const errors = recordConsoleErrors(); + const { endpoint } = makeEndpoint('value'); + const gate = makeGate(); + const managers = gatedManagers(host); + let controller: Controller | undefined; + const Text = host.Text; + function Cached() { + const ctrl = useController(); + controller = ctrl; + const value = useCache(endpoint); + return {value === undefined ? 'empty' : `value ${value}`}; + } + const tree = ( + <> + + + + + + ); + const renderer = host.createRenderer(); + try { + renderer.render(tree); + await waitUntil(() => controller !== undefined); + startTransition(() => { + void controller?.setResponse(endpoint, 7); + }); + await tick(); + gate.release(); + await waitUntil(() => renderer.read().includes('value 7')); + return { text: renderer.read(), warned: warnedNotMounted(errors) }; + } finally { + renderer.unmount(); + errors.restore(); + } +} + function expectResolved( result: Awaited>, { shared, where, settle }: Pick, @@ -448,6 +595,35 @@ export function registerPrecommitRaceTests(host: RaceHost) { expectResolved(await runRace(host, spec), spec); }); + // legacy roots commit the siblings of what suspends, so nothing is discarded there + (LegacyReact ? it.skip : it)( + 'keeps updating the mounted tree while a transition remount never commits', + async () => { + const result = await runTransitionRemount(host); + expect(result.rendered).toBe(true); + expect(result.duringTransition).toContain('value 5'); + expect(result.text).toContain('value 6'); + expect(result.settled).toBe(true); + expect(result.warned).toBe(false); + }, + ); + + it('drops a response that settles after a reset before mount', async () => { + const result = await runResetBeforeMount(host); + expect(result.seen.some(text => text.includes('value 9'))).toBe(false); + expect(result.text).toContain('value 100'); + expect(result.calls).toBe(2); + }); + + (LegacyReact ? it.skip : it)( + 'shows a transition dispatched before mount', + async () => { + const result = await runTransitionBeforeMount(host); + expect(result.text).toContain('value 7'); + expect(result.warned).toBe(false); + }, + ); + for (const settle of ['value', 'error'] as const) { const outcome = settle === 'error' ? 'an error' : 'a value'; it(`shows ${outcome} under StrictMode without calling the endpoint again`, async () => { diff --git a/packages/react/src/managers/CommitGateManager.ts b/packages/react/src/managers/CommitGateManager.ts index 57dda3df5f59..d9642e21de08 100644 --- a/packages/react/src/managers/CommitGateManager.ts +++ b/packages/react/src/managers/CommitGateManager.ts @@ -1,4 +1,6 @@ import { actionTypes } from '@data-client/core'; + +import { SSR } from '../components/LegacyReact.js'; import type { Controller, Manager, @@ -23,17 +25,26 @@ export default class CommitGateManager implements Manager { ][] = []; protected controller?: Controller; protected mounted?: Controller; + // NetworkManager reads lastReset from a store that hasn't committed, which a reset never reached + protected resetAt = 0; middleware: Middleware = controller => { this.controller = controller; return next => action => { + // a server render never commits, so holding would only leak + if (SSR) return next(action); // held results predate any reset, whichever store it reaches - if (action.type === actionTypes.RESET) this.drop(); + if (action.type === actionTypes.RESET) { + this.resetAt = action.date; + this.drop(); + } if ( controller === this.mounted || action.type !== actionTypes.SET_RESPONSE ) return next(action); + // fetched before that reset, as NetworkManager would drop it once mounted + if (action.meta.fetchedAt < this.resetAt) return Promise.resolve(); return new Promise(settle => this.held.push([action, settle])); }; }; diff --git a/packages/react/src/managers/__tests__/CommitGateManager.node.tsx b/packages/react/src/managers/__tests__/CommitGateManager.node.tsx new file mode 100644 index 000000000000..1343ece9386d --- /dev/null +++ b/packages/react/src/managers/__tests__/CommitGateManager.node.tsx @@ -0,0 +1,31 @@ +// eslint-env jest +import { Endpoint } from '@data-client/endpoint'; +import React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; + +import { CommitGateManager } from '..'; +import DataProvider from '../../components/DataProvider'; +import { getDefaultManagers } from '../../components/getDefaultManagers'; +import { useController } from '../../hooks'; + +const endpoint = new Endpoint(() => Promise.resolve(5), { name: 'served' }); + +describe('CommitGateManager on the server', () => { + it('passes results through, since a server render never commits', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const gate = new CommitGateManager(); + let ctrl: ReturnType | undefined; + function Probe() { + ctrl = useController(); + return null; + } + ReactDOMServer.renderToString( + + + , + ); + void ctrl?.setResponse(endpoint, 5); + expect(gate['held']).toEqual([]); + warn.mockRestore(); + }); +}); diff --git a/packages/react/src/managers/__tests__/CommitGateManager.web.ts b/packages/react/src/managers/__tests__/CommitGateManager.web.ts index 3de6d4d1eb05..c71ae6bab8d9 100644 --- a/packages/react/src/managers/__tests__/CommitGateManager.web.ts +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -91,6 +91,26 @@ describe('CommitGateManager', () => { expect(reduced.mock.calls[0][0]).toMatchObject({ type: 'rdc/reset' }); }); + it('drops a result fetched before a RESET that settles after it, before mount', async () => { + const gate = new CommitGateManager(); + const discarded = bind(gate); + const stale = actions.createSetResponse(endpoint, { + args: [], + response: 9, + fetchedAt: Date.now() - 10, + }); + void discarded.controller.dispatch(actions.createReset()); + // the uncommitted store still reports lastReset 0, so NetworkManager lets it through + const settled = discarded.controller.dispatch(stale); + const fresh = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + committed.mount(); + await settled; + await fresh; + expect(committed.reduced).toHaveBeenCalledTimes(1); + expect(committed.reduced.mock.calls[0][0]).toMatchObject({ response: 5 }); + }); + it('rejects the held promise when its replay fails', async () => { const gate = new CommitGateManager(); const discarded = bind(gate); diff --git a/website/src/components/Playground/editor-types/@data-client/react.d.ts b/website/src/components/Playground/editor-types/@data-client/react.d.ts index 581a83c4594d..f93acfcce35c 100644 --- a/website/src/components/Playground/editor-types/@data-client/react.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/react.d.ts @@ -25,6 +25,7 @@ declare class CommitGateManager implements Manager { ][]; protected controller?: Controller; protected mounted?: Controller; + protected resetAt: number; middleware: Middleware; init(): void; cleanup(): void;