Skip to content

Fix "Maximum update depth exceeded" when many ReferenceFields resolve at once - #11329

Merged
fzaninotto merged 1 commit into
masterfrom
fix/batch-get-many-aggregate-resolutions
Jul 30, 2026
Merged

Fix "Maximum update depth exceeded" when many ReferenceFields resolve at once#11329
fzaninotto merged 1 commit into
masterfrom
fix/batch-get-many-aggregate-resolutions

Conversation

@pysnooLab

@pysnooLab pysnooLab commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Problem

A page with enough <ReferenceField> throws and takes the page down:

Maximum update depth exceeded. This can happen when a component repeatedly calls
setState inside componentWillUpdate or componentDidUpdate. React limits the number
of nested updates to prevent infinite loops.

useGetManyAggregate aggregates the requests but not the resolutions: it resolves its pending calls one at a time, and since each belongs to a distinct useQuery, React commits each consumer separately. When a child updates its state during the commit phase (from a layout effect), those commits nest instead of batching, and past 50 React throws. Measured on the shadcn-admin-kit dashboard, one ReferenceField and one Base UI avatar per row: 25 rows pass, 35 throw. It affects every consumer of the hook: <ReferenceField>, <ReferenceInput>, <ReferenceManyField>, <ReferenceArrayField>, <AutocompleteInput> with a reference.

Solution

Write the data of all pending calls with queryClient.setQueryData inside a single notifyManager.batch, then resolve them as before to let their query leave the fetching state. Those writes are synchronous, so every dispatch stays in one transaction: react-query flushes all the observer notifications at once and all the consumers render in one commit. Wrapping the resolution loop in notifyManager.batch cannot work, because a resolved promise only makes its query transition two microtasks later, once the synchronous transaction is closed.

Calls whose query was canceled or removed while the request was in flight are skipped, so their cache entry is not resurrected. No public API change, no new option, aggregation untouched.

It also makes reference lists cheaper. 100 rows with one ReferenceField each (Chromium, React 19, median of 5 runs): commits 101 → 2, wall clock 205 → 160 ms, and no extra render (200 row renders before and after). That count is 2 rather than 3 because react-query flushes notifications on a setTimeout(0), after both dispatches, so the intermediate state (data present, isFetching still true) goes unobserved; a microtask flush would make it observable.

How To Test

Two tests added to useGetManyAggregate.spec.tsx: one asserts that all the aggregated calls resolve in a single React commit (before the fix: Expected: 1, Received: 30), the other that a query canceled mid-flight is not repopulated (fails without the guard, with Received: [{"id": 1, "title": "foo"}]).

The overflow itself is not reproducible in this repo, so it was verified against a real app. React 18.3.1 only counts a pending SyncLane at the end of a commit while React 19.1.0 also counts Default (remainingLanes & 42), and under React 18 even 200 consumers with a three-deep chain of commit-phase updates give 601 commits and no error. On shadcn-admin-kit test-base-ui-no-compat with PendingReviews uncapped to 61 rows, headless Chromium, React 19: 3 runs out of 3 throw with ra-core 5.14.3, 0 out of 3 with this branch, and the reference-heavy screens (#/reviews, #/orders, #/customers, #/orders/1, #/customers/1) render with zero console errors.

All green: ra-core 1309 tests, whole monorepo 2839 tests, including in the mode CI uses (jest --runInBand, Node 22.x).

Known limits: the error path is still resolved one call at a time, because there is no public equivalent of setQueryData for an error state; there is no React 19 test in this repo; and the cascade stays reachable whenever one commit per row is obtained some other way, so this fixes the reliable case rather than removing the class of problem.

Additional Checks

  • The PR targets master for a bugfix or a documentation fix, or next for a feature
  • The PR includes unit tests (if not possible, describe why)
  • The PR includes one or several stories (if not possible, describe why) — no story: the change is internal to a headless hook and adds no UI state, and reproducing it visually needs ~35 rows plus a child updating state during commit
  • The documentation is up to date — no doc change: no public API, option or return value changes

useGetManyAggregate aggregates the requests but not the resolutions: each
pending call belongs to a distinct useQuery, so resolving them one by one
makes React commit each consumer separately. When a child updates its state
during the commit phase, those commits nest instead of batching, and React
throws "Maximum update depth exceeded" past 50 of them. A dashboard with 35
ReferenceFields is enough.

Wrapping the loop in notifyManager.batch cannot fix it: a resolved promise
only makes its query transition two microtasks later, once the synchronous
transaction is closed. Writing the data of every pending call with
setQueryData inside one notifyManager transaction does, because those writes
are synchronous, so react-query flushes all the observer notifications at
once and every consumer renders in a single commit. Queries canceled or
removed while the request was in flight are skipped, so their cache entry is
not resurrected.
@pysnooLab
pysnooLab force-pushed the fix/batch-get-many-aggregate-resolutions branch from c889ffe to 13dc5b7 Compare July 30, 2026 15:11
@fzaninotto
fzaninotto merged commit f858ee9 into master Jul 30, 2026
15 checks passed
@fzaninotto
fzaninotto deleted the fix/batch-get-many-aggregate-resolutions branch July 30, 2026 16:11
@fzaninotto fzaninotto added this to the 5.15.2 milestone Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants