Fix "Maximum update depth exceeded" when many ReferenceFields resolve at once - #11329
Merged
Merged
Conversation
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
force-pushed
the
fix/batch-get-many-aggregate-resolutions
branch
from
July 30, 2026 15:11
c889ffe to
13dc5b7
Compare
fzaninotto
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A page with enough
<ReferenceField>throws and takes the page down:useGetManyAggregateaggregates the requests but not the resolutions: it resolves its pending calls one at a time, and since each belongs to a distinctuseQuery, 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, oneReferenceFieldand 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.setQueryDatainside a singlenotifyManager.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 innotifyManager.batchcannot 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
ReferenceFieldeach (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 asetTimeout(0), after both dispatches, so the intermediate state (data present,isFetchingstilltrue) 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, withReceived: [{"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
SyncLaneat the end of a commit while React 19.1.0 also countsDefault(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-kittest-base-ui-no-compatwithPendingReviewsuncapped 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-core1309 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
setQueryDatafor 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
masterfor a bugfix or a documentation fix, ornextfor a feature