Summary
useEntityList re-fetches whenever the pre-resolved selection option is given a fresh object identity, even when its content is unchanged and a stable queryKey is supplied. Because DataGrid rebuilds selection from the children render-prop on every render, a grid nested under a re-rendering store subscriber spins into an unbounded refetch loop (/live request storm, grid flickers "No results").
Environment
Reproduction
function GridLike({ selectionVersion }: { selectionVersion: number }) {
// new (content-identical) selection object whenever selectionVersion changes
const selection = React.useMemo(
() => resolveSelectionMeta<Author, Author>(a => a.id().name().email()),
[selectionVersion],
)
const list = useEntityList(authorDef, {
selection,
queryKey: 'stable-author-query-key', // <- stable, never changes
})
return list.$status === 'ready' ? <div>{list.length}</div> : <div>Loading…</div>
}
// 1. render with selectionVersion={0} -> 1 backend query (correct)
// 2. rerender with selectionVersion={1} (same fields, same queryKey)
// -> EXPECTED: still 1 query; ACTUAL: 2 queries
The failing test asserts exactly this: expect(queryCount).toBe(1) → Received: 2.
Expected behavior
When the caller supplies a stable queryKey (the documented cache-invalidation key), a new-but-content-identical selection object must not trigger another backend round-trip. This already works for the definer path (3rd argument), which is explicitly stabilized — the options.selection path should behave consistently.
Actual behavior
Every fresh selection object identity re-runs the data-loading effect and issues a new query. In DataGrid this is self-sustaining: a fetch completes → store.notify() → the surrounding <Entity>/store subscriber re-renders → useDataGridSetup's collection useMemo (deps include children) rebuilds selection with a new identity → effect re-fires → fetch → … unbounded. Observed in the reporting project: thousands of /_api/content/.../live requests per minute, grid permanently flickering "No results found".
Suspected root cause
packages/bindx-react/src/hooks/useEntityList.ts:
-
The definer path is deliberately stabilized via a ref keyed on the serialized query (lines ~167–177):
const resolvedMeta = definer ? resolveSelectionMeta(definer) : null
const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null
const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null)
if (definerQueryKey && resolvedMeta) {
if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) {
selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey }
}
}
const selectionMeta = definer ? selectionRef.current!.meta : options.selection! // <- raw reference
-
The options.selection path is not stabilized — selectionMeta is the raw options.selection reference.
-
selectionMeta is then a dependency of the data-loading effect by reference (line ~402):
}, [entityType, optionsKey, effectiveQueryKey, batcher, dispatcher, store, selectionMeta])
even though a stable serialized key is already available right next to it (optionsKey, and effectiveQueryKey which prefers options.queryKey, lines ~180–195).
So the options.selection consumers (DataGrid, DataView, EntityList) get refetch-on-identity, while definer callers do not. useDataGridSetup (packages/bindx-dataview/src/useDataGridSetup.ts:103–157) makes this trivially reachable: its collection useMemo lists children in its deps, so an unstable children render-prop yields a new selection object every render, which DataGrid.tsx (~line 99–106) passes straight into useEntityList.
Suggested fix
Stabilize the options.selection path the same way the definer path already is: keep a ref keyed on options.queryKey (or the serialized selection when no queryKey is given) and reuse the previous selectionMeta when the key is unchanged. Equivalently, drop selectionMeta from the effect dependency array and rely on the already-computed stable effectiveQueryKey/optionsKey. Deferring to maintainers on which is preferable, but the asymmetry between the definer and options.selection paths looks like the core defect.
Workaround shipped downstream
We applied a temporary workaround in our project, marked TODO [BindX] (<this-issue-url>): …. The workaround memoizes the DataGrid element (and thus its children closure) so the pre-resolved selection keeps a stable identity across re-renders of the surrounding store subscriber; we will remove it once this issue is resolved.
Summary
useEntityListre-fetches whenever the pre-resolvedselectionoption is given a fresh object identity, even when its content is unchanged and a stablequeryKeyis supplied. BecauseDataGridrebuildsselectionfrom thechildrenrender-prop on every render, a grid nested under a re-rendering store subscriber spins into an unbounded refetch loop (/liverequest storm, grid flickers "No results").Environment
@contember/bindx@0.1.37(version installed in the reporting project)contember/bindx@mainas ofbd9da13tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsxbug/datagrid-refetch-loop-on-unstable-children-identityReproduction
The failing test asserts exactly this:
expect(queryCount).toBe(1)→Received: 2.Expected behavior
When the caller supplies a stable
queryKey(the documented cache-invalidation key), a new-but-content-identicalselectionobject must not trigger another backend round-trip. This already works for the definer path (3rd argument), which is explicitly stabilized — theoptions.selectionpath should behave consistently.Actual behavior
Every fresh
selectionobject identity re-runs the data-loading effect and issues a newquery. InDataGridthis is self-sustaining: a fetch completes →store.notify()→ the surrounding<Entity>/store subscriber re-renders →useDataGridSetup's collectionuseMemo(deps includechildren) rebuildsselectionwith a new identity → effect re-fires → fetch → … unbounded. Observed in the reporting project: thousands of/_api/content/.../liverequests per minute, grid permanently flickering "No results found".Suspected root cause
packages/bindx-react/src/hooks/useEntityList.ts:The definer path is deliberately stabilized via a ref keyed on the serialized query (lines ~167–177):
The
options.selectionpath is not stabilized —selectionMetais the rawoptions.selectionreference.selectionMetais then a dependency of the data-loading effect by reference (line ~402):even though a stable serialized key is already available right next to it (
optionsKey, andeffectiveQueryKeywhich prefersoptions.queryKey, lines ~180–195).So the
options.selectionconsumers (DataGrid,DataView,EntityList) get refetch-on-identity, while definer callers do not.useDataGridSetup(packages/bindx-dataview/src/useDataGridSetup.ts:103–157) makes this trivially reachable: its collectionuseMemolistschildrenin its deps, so an unstablechildrenrender-prop yields a newselectionobject every render, whichDataGrid.tsx(~line 99–106) passes straight intouseEntityList.Suggested fix
Stabilize the
options.selectionpath the same way the definer path already is: keep a ref keyed onoptions.queryKey(or the serialized selection when noqueryKeyis given) and reuse the previousselectionMetawhen the key is unchanged. Equivalently, dropselectionMetafrom the effect dependency array and rely on the already-computed stableeffectiveQueryKey/optionsKey. Deferring to maintainers on which is preferable, but the asymmetry between the definer andoptions.selectionpaths looks like the core defect.Workaround shipped downstream
We applied a temporary workaround in our project, marked
TODO [BindX] (<this-issue-url>): …. The workaround memoizes theDataGridelement (and thus itschildrenclosure) so the pre-resolvedselectionkeeps a stable identity across re-renders of the surrounding store subscriber; we will remove it once this issue is resolved.