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/.changeset/precommit-fetch.md b/.changeset/precommit-fetch.md new file mode 100644 index 000000000000..4d5cb93db5f0 --- /dev/null +++ b/.changeset/precommit-fetch.md @@ -0,0 +1,15 @@ +--- +'@data-client/react': patch +--- + +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. 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. + +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 new file mode 100644 index 000000000000..8ddcb13e282e --- /dev/null +++ b/docs/core/api/CommitGateManager.md @@ -0,0 +1,70 @@ +--- +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 + +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 React Native, [DataProvider](./DataProvider.md#managers) adds it first to its `managers` for you. + + + +```ts +// highlight-next-line +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) +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, and results fetched before it that settle later. +- During server rendering, nothing is held, since a server render never mounts. + +#### Dispatched Actions + +- [setResponse](./Controller.md#setResponse) replays each held result into the mounted store. 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/packages/react/src/components/DataProvider.tsx b/packages/react/src/components/DataProvider.tsx index 50cadf139809..ce193a0bd24a 100644 --- a/packages/react/src/components/DataProvider.tsx +++ b/packages/react/src/components/DataProvider.tsx @@ -14,6 +14,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'; @@ -50,8 +51,9 @@ export default function DataProvider({ 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 new file mode 100644 index 000000000000..8769b1aba3c0 --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.native.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import { Text } from 'react-native'; +import TestRenderer from 'react-test-renderer'; + +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; + 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..bce9dff74388 --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.node-suite.tsx @@ -0,0 +1,649 @@ +/** + * Race: a fetch resolves while DataProvider has not committed, because a + * 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 + * 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 { 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'; +import { + use, + lazy, + startTransition, + useState, + Activity, + StrictMode, + Suspense, +} from 'react'; +import type { ReactElement, ReactNode } from 'react'; + +import { useCache, useController, useFetch, useSuspense } from '../../hooks'; +import { CommitGateManager } from '../../managers'; +import DataProvider from '../DataProvider'; +import ErrorBoundary from '../ErrorBoundary'; +import { getDefaultManagers } from '../getDefaultManagers'; +import { LegacyReact } from '../LegacyReact'; + +export interface RaceRenderer { + render(node: ReactElement): void; + read(): string; + unmount(): void; +} + +export interface RaceHost { + createRenderer(): RaceRenderer; + Text: (props: { children?: ReactNode }) => ReactElement; + /** DataProvider adds CommitGateManager itself, as on React Native */ + addsGate?: boolean; +} + +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 tick() { + return new Promise(resolve => { + 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: Settle) { + 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 }; +} + +// 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'; +} + +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}`}; +} + +// web opts in; on native DataProvider adds the gate itself +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); + else if (!gate.done) throw gate; + return null; +} + +// '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; + /** 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( + 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, + throwOnce = false, + mutate = false, + freshArray = false, + }: RaceSpec, +) { + const errors = recordConsoleErrors(); + const { endpoint, getCalls } = makeEndpoint(settle); + const gate = makeGate(); + 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, extra)); + let controller: Controller | undefined; + function Provider({ children }: { children: ReactNode }) { + const [managers] = useState(initManagers); + return ( + + (controller = c)} /> + {children} + + ); + } + const Text = host.Text; + const Reader = reader === 'fetch' ? FetchReader : SuspenseReader; + const provider = ( + + {readerTree( + host, + endpoint, + Reader, + 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 root = + LegacyReact ? + outer}>{tree} + : tree; + const element = strict ? {root} : root; + const expected = throwOnce ? 'error middleware threw' : expectedText(settle); + const renderer = host.createRenderer(); + try { + if (transition) startTransition(() => renderer.render(element)); + else renderer.render(element); + // the extra tick lets the fetch settle while the render is still parked + // a lazy layout cannot fetch before it loads + if (where !== 'lazy') await waitUntil(() => getCalls() > 0); + await tick(); + const beforeRelease = renderer.read(); + 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, + afterMutate, + calls: getCalls(), + warned: warnedNotMounted(errors), + }; + } finally { + renderer.unmount(); + errors.restore(); + } +} + +// a hidden Activity renders its tree without mounting Effects, so DataProvider stays uncommitted until shown +async function runHiddenActivity(host: RaceHost, settle: Settle) { + const errors = recordConsoleErrors(); + const { endpoint, getCalls } = makeEndpoint(settle); + const managers = gatedManagers(host); + const tree = (mode: 'hidden' | 'visible') => ( + + + {readerTree(host, endpoint, SuspenseReader)} + + + ); + const expected = expectedText(settle); + 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); + return { + beforeReveal, + callsBeforeReveal, + text: renderer.read(), + calls: getCalls(), + warned: warnedNotMounted(errors), + }; + } finally { + renderer.unmount(); + errors.restore(); + } +} + +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, +) { + const expected = expectedText(settle); + 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); + } + expect(result.text).toContain(expected); + if (shared || LegacyReact) { + expect(result.calls).toBe(1); + } else { + // every discarded provider brought its own NetworkManager + expect(result.calls).toBeGreaterThan(1); + expect(result.calls).toBeLessThan(10); + } + expect(result.warned).toBe(false); +} + +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 = g.IS_REACT_ACT_ENVIRONMENT; + g.IS_REACT_ACT_ENVIRONMENT = false; + }); + + afterAll(() => { + g.IS_REACT_ACT_ENVIRONMENT = prevAct; + }); + + const base: Omit = { + 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 = + !(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('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); + }); + + // 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 () => { + const spec = { ...base, settle, strict: true }; + 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 = expectedText(settle); + 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); + }, + ); + } + }); +} 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..eb491ebab48c --- /dev/null +++ b/packages/react/src/components/__tests__/precommit-race.tsx @@ -0,0 +1,44 @@ +import type { ReactElement, ReactNode } from 'react'; + +import { LegacyReact } from '../LegacyReact'; +import { registerPrecommitRaceTests } from './precommit-race.node-suite'; +import type { RaceHost, RaceRenderer } from './precommit-race.node-suite'; + +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 => dom.render(node, container), + unmount: () => dom.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 = createRoot(container); + return { + render(node) { + root.render(node); + }, + read() { + return container.textContent ?? ''; + }, + unmount() { + root.unmount(); + container.remove(); + }, + }; + }, +}; + +registerPrecommitRaceTests(host); diff --git a/packages/react/src/components/platformManagers.native.ts b/packages/react/src/components/platformManagers.native.ts new file mode 100644 index 000000000000..45a85ecd1fa7 --- /dev/null +++ b/packages/react/src/components/platformManagers.native.ts @@ -0,0 +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; +// keyed on the instance that owns the fetch, since the array can be new each render +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; + let gate = gates.get(owner); + if (!gate) gates.set(owner, (gate = new CommitGateManager())); + return [gate, ...managers]; +} diff --git a/packages/react/src/components/platformManagers.ts b/packages/react/src/components/platformManagers.ts new file mode 100644 index 000000000000..1729510983d7 --- /dev/null +++ b/packages/react/src/components/platformManagers.ts @@ -0,0 +1,6 @@ +import type { Manager } from '@data-client/core'; + +/** Managers DataProvider adds on this platform; 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..d9642e21de08 --- /dev/null +++ b/packages/react/src/managers/CommitGateManager.ts @@ -0,0 +1,96 @@ +import { actionTypes } from '@data-client/core'; + +import { SSR } from '../components/LegacyReact.js'; +import type { + Controller, + Manager, + Middleware, + SetResponseAction, +} 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 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. + * + * 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: [ + action: SetResponseAction, + settle: (result?: Promise) => void, + ][] = []; + 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.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])); + }; + }; + + 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, settle] of held) { + try { + settle(mounted.dispatch(action)); + } catch (error) { + // 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 ? undefined : ( + new Promise(resolve => + resolve( + mounted.resolve(action.endpoint, { + args: action.args, + response: error as Error, + fetchedAt: action.meta.fetchedAt, + error: true, + }), + ), + ).catch(() => {}) + ), + ); + } + } + } + + protected drop() { + for (const [, settle] of this.held) settle(); + this.held = []; + } +} 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 new file mode 100644 index 000000000000..c71ae6bab8d9 --- /dev/null +++ b/packages/react/src/managers/__tests__/CommitGateManager.web.ts @@ -0,0 +1,185 @@ +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', + }); + }); + + 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('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); + 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); + }); + + 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()); + const second = discarded.controller.dispatch(setResponse()); + const committed = bind(gate); + const failure = new Error('middleware threw'); + committed.reduced.mockImplementationOnce(() => { + throw failure; + }); + committed.mount(); + await first; + await second; + 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 }); + }); + + 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( + 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).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 }); + }); +}); 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/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 333ed9e206fd..67a957277a48 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)) +- [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)) @@ -319,6 +320,40 @@ On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.ext ## Other improvements +### Expo Router layouts and DataProvider {#commit-gate} + +[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. + +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 title="app/_layout.tsx" +import { Stack } from 'expo-router'; +import { + CommitGateManager, + DataProvider, + getDefaultManagers, +} from '@data-client/react'; + +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} [Controller.set()](/docs/api/Controller#set) previously accepted any value for a schema, so a typo or a wrong diff --git a/website/sidebars.json b/website/sidebars.json index 9e68ba3cc78c..78cf6abbc5a9 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 a8c1a887d847..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 @@ -1,5 +1,5 @@ import * as _data_client_core from '@data-client/core'; -import { NetworkManager, Manager, State, GCInterface, Controller, 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'; @@ -8,6 +8,31 @@ 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 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. + * + * 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: [ + action: SetResponseAction, + settle: (result?: Promise) => void + ][]; + protected controller?: Controller; + protected mounted?: Controller; + protected resetAt: number; + 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'; @@ -305,4 +330,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 };