From 1156f2a7a0eeac580e83fa5d601967d12b6a8993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jind=C5=99ich=20Krupka?= Date: Sat, 16 May 2026 11:47:54 +0200 Subject: [PATCH 1/3] test: failing repro for useEntityList refetch on unstable pre-resolved selection identity --- .../selectionIdentityRefetchLoop.test.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsx diff --git a/tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsx b/tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsx new file mode 100644 index 0000000..47b39b2 --- /dev/null +++ b/tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsx @@ -0,0 +1,118 @@ +// Regression test for +import '../../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { + BindxProvider, + defineSchema, + entityDef, + MockAdapter, + resolveSelectionMeta, + scalar, + useEntityList, +} from '@contember/bindx-react' + +afterEach(() => { + cleanup() +}) + +interface Author { + id: string + name: string + email: string +} + +interface TestSchema { + Author: Author +} + +const schema = defineSchema({ + entities: { + Author: { + fields: { + id: scalar(), + name: scalar(), + email: scalar(), + }, + }, + }, +}) + +const authorDef = entityDef('Author') + +function createMockData() { + return { + Author: { + 'author-1': { id: 'author-1', name: 'John Doe', email: 'john@example.com' }, + 'author-2': { id: 'author-2', name: 'Jane Smith', email: 'jane@example.com' }, + }, + } +} + +describe('useEntityList — pre-resolved selection identity', () => { + test('should not refetch when an identical selection object with a stable queryKey gets a fresh identity', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + // Count backend round-trips. + let queryCount = 0 + const originalQuery = adapter.query.bind(adapter) + adapter.query = async (...args: Parameters) => { + queryCount++ + return originalQuery(...args) + } + + // Mirrors how DataGrid drives useEntityList: it passes a pre-resolved + // `selection` (rebuilt by useDataGridSetup's collection useMemo, whose + // deps include the `children` render-prop) together with a stable + // serialized `queryKey`. When the grid is nested under a re-rendering + // store subscriber, `children` gets a fresh identity, so `selection` + // becomes a brand-new object of identical content — while `queryKey` + // stays constant. + // + // `selectionVersion` controls the `selection` object identity + // deterministically: bumping it once produces exactly one new + // (content-identical) selection object, so the assertion is stable + // and the test cannot livelock on the very loop it is guarding + // against. + function GridLike({ selectionVersion }: { selectionVersion: number }): React.ReactElement { + const selection = React.useMemo( + () => resolveSelectionMeta(a => a.id().name().email()), + [selectionVersion], + ) + const list = useEntityList(authorDef, { + selection, + queryKey: 'stable-author-query-key', + }) + if (list.$status !== 'ready') return
Loading…
+ return
{list.length}
+ } + + const { container, rerender } = render( + + + , + ) + + await waitFor(() => { + expect(container.querySelector('[data-testid="count"]')?.textContent).toBe('2') + }) + + expect(queryCount).toBe(1) + + // One deliberate selection-identity change. Nothing else about the + // query changed: same fields, same stable queryKey. + rerender( + + + , + ) + + // Let any erroneously-scheduled refetch effect fire. + await new Promise(resolve => setTimeout(resolve, 50)) + + // The stable queryKey identifies this query; a new-but-identical + // selection object must NOT trigger another backend round-trip. + expect(queryCount).toBe(1) + }) +}) From 1addc630ef461ffe5ec57dd250760dee20a4d2b7 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 1 Jun 2026 15:01:16 +0200 Subject: [PATCH 2/3] fix(bindx-react): stabilize useEntityList selection identity to prevent refetch loop The options.selection path used the raw object reference for selectionMeta, which flowed into the data-loading effect's dependency array. Callers passing a content-identical-but-new selection object (e.g. DataGrid rebuilding its selection when children identity changes) triggered redundant backend queries, producing a self-sustaining /live request storm via fetch -> notify -> rerender. Unify both selection paths (definer 3rd-arg and pre-resolved options.selection) behind a single selectionRef keyed on the serialized query content. selectionMeta now keeps stable identity while the query content is unchanged, and effectiveQueryKey derives from that same content key. Legitimate refetches (changed fields without a queryKey, changed filter/orderBy/limit/offset, or a changed queryKey) still fire. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bindx-react/src/hooks/useEntityList.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 0a4de0e..0f179aa 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -163,18 +163,22 @@ export function useEntityList( const { store, dispatcher, batcher } = useBindxContext() // --- Selection resolution --- + // Both selection paths (definer 3rd-arg and pre-resolved options.selection) + // are stabilized identically: the raw meta is resolved every render, but its + // reference is only swapped when the serialized query content actually + // changes. This keeps `selectionMeta` identity stable across content-identical + // renders (e.g. DataGrid rebuilding a new-but-equal selection object), which + // in turn keeps the data-loading effect from refiring spuriously. // resolveSelectionMeta is a pure function; useRef is called unconditionally. - const resolvedMeta = definer ? resolveSelectionMeta(definer) : null - const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null - const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null) + const rawMeta = definer ? resolveSelectionMeta(definer) : options.selection! + const selectionContentKey = JSON.stringify(buildQueryFromSelection(rawMeta)) + const selectionRef = useRef<{ meta: SelectionMeta; contentKey: string } | null>(null) - if (definerQueryKey && resolvedMeta) { - if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) { - selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey } - } + if (!selectionRef.current || selectionRef.current.contentKey !== selectionContentKey) { + selectionRef.current = { meta: rawMeta, contentKey: selectionContentKey } } - const selectionMeta = definer ? selectionRef.current!.meta : options.selection! + const selectionMeta = selectionRef.current.meta // --- Stable options key --- const optionsKey = useMemo( @@ -188,11 +192,12 @@ export function useEntityList( ) // --- Effective query key for cache invalidation --- + // Derived from the stable selectionContentKey so it keeps identity while the + // query content is unchanged, even if the caller passes a fresh selection object. const effectiveQueryKey = useMemo(() => { if (options.queryKey) return options.queryKey - const query = buildQueryFromSelection(selectionMeta) - return JSON.stringify({ entityType, query }) - }, [options.queryKey, selectionMeta, entityType]) + return JSON.stringify({ entityType, query: selectionContentKey }) + }, [options.queryKey, selectionContentKey, entityType]) // --- List state tracking --- const listStateRef = useRef<{ From 02d8822f764800b505511ec802b50434d0c459e8 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 1 Jun 2026 15:55:24 +0200 Subject: [PATCH 3/3] refactor(bindx-react): avoid double-encoding the effective query key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectionContentKey is already the serialized query; wrapping it in another JSON.stringify produced a double-encoded string. Namespace it by entity type directly instead. Purely internal (effect dep / cache key) — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/bindx-react/src/hooks/useEntityList.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 0f179aa..afc2d19 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -196,7 +196,9 @@ export function useEntityList( // query content is unchanged, even if the caller passes a fresh selection object. const effectiveQueryKey = useMemo(() => { if (options.queryKey) return options.queryKey - return JSON.stringify({ entityType, query: selectionContentKey }) + // selectionContentKey is already the serialized query; just namespace it + // by entity type (avoids double-encoding the JSON string). + return `${entityType}:${selectionContentKey}` }, [options.queryKey, selectionContentKey, entityType]) // --- List state tracking ---