From 1d58900e5b98615c8d3eb0e69d89f38b454f3154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 10:26:51 +0100 Subject: [PATCH 1/7] test: failing repro for Entity keeping stale selection when its children change --- .../jsx/entityBranchSwitchSelection.test.tsx | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/react/jsx/entityBranchSwitchSelection.test.tsx diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx new file mode 100644 index 00000000..1ffe0eaa --- /dev/null +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -0,0 +1,55 @@ +// Regression test for +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act } from '@testing-library/react' +import React, { useState } from 'react' +import { BindxProvider, MockAdapter, Entity, Field } from '@contember/bindx-react' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../shared' + +afterEach(() => { + cleanup() +}) + +describe(' reused across conditional branches', () => { + test('should collect the new children selection when the branch switches but entity and by stay the same', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + let setTab: (tab: 'title' | 'content') => void = () => {} + + // Two tabs render an for the same record at the same position in the + // tree, with different children. React reuses the instance; only `children` changes. + function App() { + const [tab, setLocalTab] = useState<'title' | 'content'>('title') + setTab = setLocalTab + + return tab === 'title' + ? ( + + {article =>
} +
+ ) + : ( + + {article =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + + act(() => setTab('content')) + + await waitFor(() => { + expect(queryByTestId(container, 'content')).not.toBeNull() + }) + expect(getByTestId(container, 'content').textContent).toBe('This is the content') + }) +}) From 82db7a3e94074009c355b4bd1745a6c50a2abeb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 10:44:43 +0100 Subject: [PATCH 2/7] fix(bindx-react): collect the selection again when Entity or EntityList children change React reuses an or across conditional branches that differ only in their children. The collection was memoized on entity type and by/options alone, so the new children rendered against the previous selection and threw UnfetchedFieldError. The children function is now part of the memo key; a store-driven re-render keeps the same children and does not collect again, and an unchanged query keeps the same result object. Fixes #154 --- .../bindx-react/src/hooks/useRootSelection.ts | 1 + .../src/hooks/useSelectionCollection.ts | 21 ++++++--- .../src/jsx/components/EntityList.tsx | 1 + .../jsx/entityBranchSwitchSelection.test.tsx | 44 ++++++++++++++++++- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts index 4afccf92..642f9add 100644 --- a/packages/bindx-react/src/hooks/useRootSelection.ts +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -80,6 +80,7 @@ export function useRootSelection(params: UseRootSelectionParams): SelectionColle const runtimeResult = useSelectionCollection({ entityType, depsKey: isWalkSkipped ? `${depsKey}::compiled` : depsKey, + source: children, collect: collector => { if (isWalkSkipped) { return null diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index 5aab2f1f..45ff1880 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -17,6 +17,13 @@ export interface UseSelectionCollectionOptions { depsKey: string /** Collector function — receives a collector proxy, returns JSX for analysis */ collect: (collector: EntityAccessor) => ReactNode + /** + * The user-supplied render function `collect` wraps. A new identity re-runs the + * collection: React reuses an across conditional branches that differ only + * in their children, and those children may read fields the previous ones did not. + * The collected result stays the same object while its query does not change. + */ + source?: unknown /** Extra data to include in the query key (e.g., filter/orderBy for lists) */ queryKeyExtra?: Record } @@ -47,19 +54,21 @@ interface SelectionCache { * 3. Analyzes returned JSX for component-level selections * 4. Merges both sources of selection * - * **Limitation:** Selection is collected once per `depsKey`. - * Fields accessed conditionally in the collect function are only captured - * if they are accessed during the initial collection phase. + * Selection is collected once per `depsKey` and `source`. Re-renders driven by the + * store keep the same `source`, so they do not re-run the collection. + * + * **Limitation:** Fields accessed conditionally in the collect function are only + * captured if they are accessed when the collection runs. * * @internal This hook is for internal use only. */ export function useSelectionCollection( options: UseSelectionCollectionOptions, ): SelectionCollectionResult { - const { entityType, depsKey, collect, queryKeyExtra } = options + const { entityType, depsKey, collect, source, queryKeyExtra } = options const bindxContext = useContext(BindxContext) - // Stable collect ref - we use a ref to avoid re-running useMemo on every render + // `collect` is usually an inline closure; `source` decides when collection re-runs. const collectRef = useRef(collect) collectRef.current = collect @@ -116,7 +125,7 @@ export function useSelectionCollection( selectionCacheRef.current = newCache return newCache - }, [entityType, depsKey, schemaRegistry]) // Only depend on entity identity, deps key, and schema + }, [entityType, depsKey, source, schemaRegistry]) return result } diff --git a/packages/bindx-react/src/jsx/components/EntityList.tsx b/packages/bindx-react/src/jsx/components/EntityList.tsx index a87f8011..2c4e35ff 100644 --- a/packages/bindx-react/src/jsx/components/EntityList.tsx +++ b/packages/bindx-react/src/jsx/components/EntityList.tsx @@ -79,6 +79,7 @@ function EntityListComponent>({ const { selection, queryKey: selectionQueryKey } = useSelectionCollection({ entityType, depsKey: optionsKey, + source: children, collect: collector => children(collector as unknown as EntityAccessor>, 0), queryKeyExtra: { filter, orderBy, limit, offset }, }) diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index 1ffe0eaa..9b6db6be 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -1,9 +1,9 @@ -// Regression test for +// Regression test for https://github.com/contember/bindx/issues/154 import '../../setup' import { describe, test, expect, afterEach } from 'bun:test' import { render, waitFor, cleanup, act } from '@testing-library/react' import React, { useState } from 'react' -import { BindxProvider, MockAdapter, Entity, Field } from '@contember/bindx-react' +import { BindxProvider, MockAdapter, Entity, EntityList, Field } from '@contember/bindx-react' import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../shared' afterEach(() => { @@ -52,4 +52,44 @@ describe(' reused across conditional branches', () => { }) expect(getByTestId(container, 'content').textContent).toBe('This is the content') }) + + test('should collect the new children selection when an branch switches with the same options', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + let setTab: (tab: 'title' | 'content') => void = () => {} + + function App() { + const [tab, setLocalTab] = useState<'title' | 'content'>('title') + setTab = setLocalTab + + return tab === 'title' + ? ( + + {article =>
} +
+ ) + : ( + + {article =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title-article-1')).not.toBeNull() + }) + + act(() => setTab('content')) + + await waitFor(() => { + expect(queryByTestId(container, 'content-article-1')).not.toBeNull() + }) + expect(getByTestId(container, 'content-article-1').textContent).toBe('This is the content') + }) }) From 08f5dcf5bd2d2baade232f2ef0a718b3b22b8e27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 10:56:11 +0100 Subject: [PATCH 3/7] fix(bindx-react): only grow the collected selection within one depsKey Re-collecting on every new children function made a field toggled by parent state refetch on every toggle, and hiding a field shrank the query. The selection now only grows: children covered by the cached selection return it unchanged, and new fields are merged into a deep copy so neither the cached selection nor component selections are mutated. --- .../src/hooks/useSelectionCollection.ts | 67 ++++++++++++-- .../src/jsx/components/EntityList.tsx | 3 +- .../jsx/entityBranchSwitchSelection.test.tsx | 89 +++++++++++++++++++ 3 files changed, 151 insertions(+), 8 deletions(-) diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index 45ff1880..c036f368 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -3,7 +3,7 @@ import { createCollectorProxy } from '../jsx/proxy.js' import { collectSelection, debugSelection } from '../jsx/analyzer.js' import { mergeSelections } from '../jsx/SelectionMeta.js' import { buildQueryFromSelection, SelectionScope } from '@contember/bindx' -import type { SelectionMeta, SchemaRegistry } from '@contember/bindx' +import type { SelectionMeta, SelectionFieldMeta, SchemaRegistry } from '@contember/bindx' import type { EntityAccessor } from '../jsx/types.js' import { BindxContext } from './BackendAdapterContext.js' @@ -21,7 +21,10 @@ export interface UseSelectionCollectionOptions { * The user-supplied render function `collect` wraps. A new identity re-runs the * collection: React reuses an across conditional branches that differ only * in their children, and those children may read fields the previous ones did not. - * The collected result stays the same object while its query does not change. + * Within one `depsKey` the selection only grows — fields the new children no longer + * read stay selected — so the result changes (and the data is refetched) only when a + * field nobody requested before appears. Pass a stable `source` from a parent that + * re-renders often: every new identity runs the collection again. */ source?: unknown /** Extra data to include in the query key (e.g., filter/orderBy for lists) */ @@ -44,6 +47,37 @@ export interface SelectionCollectionResult { interface SelectionCache { selection: SelectionMeta queryKey: string + /** The `depsKey` the selection was grown under; a different one starts afresh. */ + depsKey: string +} + +/** Deep copy, so a merge into it cannot reach selections other code still holds. */ +function cloneSelection(selection: SelectionMeta): SelectionMeta { + const fields = new Map() + for (const [key, field] of selection.fields) { + fields.set(key, field.nested ? { ...field, nested: cloneSelection(field.nested) } : { ...field }) + } + return { fields } +} + +/** + * Whether every field `selection` requests is already requested by `covering`, + * recursively and with the same has-many parameters. + */ +function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): boolean { + for (const [key, field] of selection.fields) { + const existing = covering.fields.get(key) + if (!existing || existing.fieldName !== field.fieldName) { + return false + } + if (field.hasManyParams && JSON.stringify(field.hasManyParams) !== JSON.stringify(existing.hasManyParams)) { + return false + } + if (field.nested && !(existing.nested && isSelectionCovered(field.nested, existing.nested))) { + return false + } + } + return true } /** @@ -54,8 +88,9 @@ interface SelectionCache { * 3. Analyzes returned JSX for component-level selections * 4. Merges both sources of selection * - * Selection is collected once per `depsKey` and `source`. Re-renders driven by the - * store keep the same `source`, so they do not re-run the collection. + * Selection is collected once per `depsKey` and `source` and only grows within one + * `depsKey`. Re-renders driven by the store keep the same `source`, so they do not + * re-run the collection. * * **Limitation:** Fields accessed conditionally in the collect function are only * captured if they are accessed when the collection runs. @@ -96,8 +131,23 @@ export function useSelectionCollection( } // Convert scope to SelectionMeta and merge with JSX selection - const selection = scope.toSelectionMeta() - mergeSelections(selection, jsxSel) + const collected = scope.toSelectionMeta() + mergeSelections(collected, jsxSel) + + // Grow, never shrink, within one depsKey: children that stop reading a field + // (a hidden section, a switched branch) must not trigger a refetch, and coming + // back to them must find the field still fetched. + const cache = selectionCacheRef.current + const growsCache = cache !== null && cache.depsKey === depsKey + if (growsCache && isSelectionCovered(collected, cache.selection)) { + return cache + } + // The collected selection can share nested objects with component selections + // and the cached one with useEntity, so the union is built in a fresh copy. + const selection = growsCache ? cloneSelection(cache.selection) : collected + if (growsCache) { + mergeSelections(selection, collected) + } // Debug output can be enabled via the debug prop on BindxProvider if (bindxContext?.debug) { @@ -112,8 +162,10 @@ export function useSelectionCollection( : JSON.stringify(query) // If the selection hasn't actually changed, return cached values - const cache = selectionCacheRef.current if (cache && cache.queryKey === newQueryKey) { + if (cache.depsKey !== depsKey) { + cache.depsKey = depsKey + } return cache } @@ -121,6 +173,7 @@ export function useSelectionCollection( const newCache: SelectionCache = { selection, queryKey: newQueryKey, + depsKey, } selectionCacheRef.current = newCache diff --git a/packages/bindx-react/src/jsx/components/EntityList.tsx b/packages/bindx-react/src/jsx/components/EntityList.tsx index 2c4e35ff..81b351ea 100644 --- a/packages/bindx-react/src/jsx/components/EntityList.tsx +++ b/packages/bindx-react/src/jsx/components/EntityList.tsx @@ -38,7 +38,8 @@ export interface EntityListProps = Recor /** * EntityList component - orchestrates the two-pass rendering approach for lists. * - * Phase 1 (Collection): Renders children once with collector proxy to determine which fields are needed + * Phase 1 (Collection): Renders children with a collector proxy to determine which fields are needed — + * again whenever the children function changes, growing the selection within the same filter/order/page * Phase 2 (Loading): Fetches data based on collected selection * Phase 3 (Runtime): Renders children for each item with real data accessors * diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index 9b6db6be..17b1dabc 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -92,4 +92,93 @@ describe(' reused across conditional branches', () => { }) expect(getByTestId(container, 'content-article-1').textContent).toBe('This is the content') }) + + test('should not refetch when children stop reading a field or come back to it', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + let queryCount = 0 + adapter.query = (...args) => { + queryCount++ + return query(...args) + } + + let setShowContent: (show: boolean) => void = () => {} + + function App() { + const [showContent, setLocalShowContent] = useState(true) + setShowContent = setLocalShowContent + + return ( + + {article => ( +
+
+ {showContent &&
} +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'content')).not.toBeNull() + }) + const queriesAfterLoad = queryCount + + act(() => setShowContent(false)) + act(() => setShowContent(true)) + act(() => setShowContent(false)) + act(() => setShowContent(true)) + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(queryCount).toBe(queriesAfterLoad) + expect(getByTestId(container, 'content').textContent).toBe('This is the content') + }) + + test('should keep the selection and skip refetching when the parent re-renders with the same fields', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + let queryCount = 0 + adapter.query = (...args) => { + queryCount++ + return query(...args) + } + + let rerender: () => void = () => {} + + function App() { + const [, setTick] = useState(0) + rerender = () => setTick(tick => tick + 1) + + return ( + + {article =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'title')).not.toBeNull() + }) + const queriesAfterLoad = queryCount + + act(() => rerender()) + act(() => rerender()) + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(queryCount).toBe(queriesAfterLoad) + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) }) From 3cec6e58881090b7b7d6b6f0e5957fe37a070993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 11:02:37 +0100 Subject: [PATCH 4/7] fix(bindx-react): grow the selection only for the same entity type and registry Neither Entity's by key nor EntityList's options key names the entity, so an EntityList reused for another entity with the same options merged the previous entity's fields into its query. The cache now records entity type, depsKey and schema registry, and grows only when all match. With a compiled selection the runtime walk no longer grows, so validate mode diffs against what the current children read. --- .../bindx-react/src/hooks/useRootSelection.ts | 3 ++ .../src/hooks/useSelectionCollection.ts | 42 ++++++++++------ .../src/jsx/components/EntityList.tsx | 2 +- .../jsx/entityBranchSwitchSelection.test.tsx | 50 +++++++++++++++++++ 4 files changed, 82 insertions(+), 15 deletions(-) diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts index 642f9add..490bd3b9 100644 --- a/packages/bindx-react/src/hooks/useRootSelection.ts +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -81,6 +81,9 @@ export function useRootSelection(params: UseRootSelectionParams): SelectionColle entityType, depsKey: isWalkSkipped ? `${depsKey}::compiled` : depsKey, source: children, + // With a compiled selection the walk only feeds the validate-mode diff, which must + // compare against what these children read, not a union with earlier ones. + grow: compiledResult === null, collect: collector => { if (isWalkSkipped) { return null diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index c036f368..83221ea0 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -21,12 +21,18 @@ export interface UseSelectionCollectionOptions { * The user-supplied render function `collect` wraps. A new identity re-runs the * collection: React reuses an across conditional branches that differ only * in their children, and those children may read fields the previous ones did not. - * Within one `depsKey` the selection only grows — fields the new children no longer - * read stay selected — so the result changes (and the data is refetched) only when a - * field nobody requested before appears. Pass a stable `source` from a parent that - * re-renders often: every new identity runs the collection again. + * For one entity type and `depsKey` the selection only grows — fields the new children + * no longer read stay selected — so the result changes (and the data is refetched) only + * when a field nobody requested before appears. Pass a stable `source` from a parent + * that re-renders often: every new identity runs the collection again. */ source?: unknown + /** + * Whether the selection grows across collections (default `true`). Off, every + * collection stands on its own — for a caller that fetches a different selection and + * only compares against this one. + */ + grow?: boolean /** Extra data to include in the query key (e.g., filter/orderBy for lists) */ queryKeyExtra?: Record } @@ -47,8 +53,10 @@ export interface SelectionCollectionResult { interface SelectionCache { selection: SelectionMeta queryKey: string - /** The `depsKey` the selection was grown under; a different one starts afresh. */ + /** What the selection was grown under; a change in any of them starts afresh. */ + entityType: string depsKey: string + schemaRegistry: unknown } /** Deep copy, so a merge into it cannot reach selections other code still holds. */ @@ -88,8 +96,8 @@ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): * 3. Analyzes returned JSX for component-level selections * 4. Merges both sources of selection * - * Selection is collected once per `depsKey` and `source` and only grows within one - * `depsKey`. Re-renders driven by the store keep the same `source`, so they do not + * Selection is collected once per `depsKey` and `source` and, unless `grow` is off, + * only grows for one entity type and `depsKey`. Re-renders driven by the store keep the same `source`, so they do not * re-run the collection. * * **Limitation:** Fields accessed conditionally in the collect function are only @@ -100,7 +108,7 @@ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): export function useSelectionCollection( options: UseSelectionCollectionOptions, ): SelectionCollectionResult { - const { entityType, depsKey, collect, source, queryKeyExtra } = options + const { entityType, depsKey, collect, source, grow = true, queryKeyExtra } = options const bindxContext = useContext(BindxContext) // `collect` is usually an inline closure; `source` decides when collection re-runs. @@ -134,11 +142,15 @@ export function useSelectionCollection( const collected = scope.toSelectionMeta() mergeSelections(collected, jsxSel) - // Grow, never shrink, within one depsKey: children that stop reading a field + // Grow, never shrink, for one entity type and depsKey: children that stop reading a field // (a hidden section, a switched branch) must not trigger a refetch, and coming // back to them must find the field still fetched. const cache = selectionCacheRef.current - const growsCache = cache !== null && cache.depsKey === depsKey + const growsCache = grow + && cache !== null + && cache.entityType === entityType + && cache.depsKey === depsKey + && cache.schemaRegistry === schemaRegistry if (growsCache && isSelectionCovered(collected, cache.selection)) { return cache } @@ -163,9 +175,9 @@ export function useSelectionCollection( // If the selection hasn't actually changed, return cached values if (cache && cache.queryKey === newQueryKey) { - if (cache.depsKey !== depsKey) { - cache.depsKey = depsKey - } + cache.entityType = entityType + cache.depsKey = depsKey + cache.schemaRegistry = schemaRegistry return cache } @@ -173,12 +185,14 @@ export function useSelectionCollection( const newCache: SelectionCache = { selection, queryKey: newQueryKey, + entityType, depsKey, + schemaRegistry, } selectionCacheRef.current = newCache return newCache - }, [entityType, depsKey, source, schemaRegistry]) + }, [entityType, depsKey, source, grow, schemaRegistry]) return result } diff --git a/packages/bindx-react/src/jsx/components/EntityList.tsx b/packages/bindx-react/src/jsx/components/EntityList.tsx index 81b351ea..5fb887ea 100644 --- a/packages/bindx-react/src/jsx/components/EntityList.tsx +++ b/packages/bindx-react/src/jsx/components/EntityList.tsx @@ -39,7 +39,7 @@ export interface EntityListProps = Recor * EntityList component - orchestrates the two-pass rendering approach for lists. * * Phase 1 (Collection): Renders children with a collector proxy to determine which fields are needed — - * again whenever the children function changes, growing the selection within the same filter/order/page + * again whenever the children function changes, growing the selection for the same entity and filter/order/page * Phase 2 (Loading): Fetches data based on collected selection * Phase 3 (Runtime): Renders children for each item with real data accessors * diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index 17b1dabc..7bc493f5 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -181,4 +181,54 @@ describe(' reused across conditional branches', () => { expect(queryCount).toBe(queriesAfterLoad) expect(getByTestId(container, 'title').textContent).toBe('Hello World') }) + + test('should not carry fields of the previous entity into an reused for another entity', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + const requestedFields: Record = {} + adapter.query = (queries, options) => { + for (const entry of queries) { + const fields = 'spec' in entry ? entry.spec.fields.map(field => field.sourcePath[0] ?? field.name) : [] + requestedFields[entry.entityType] = fields + } + return query(queries, options) + } + + let setTab: (tab: 'articles' | 'authors') => void = () => {} + + function App() { + const [tab, setLocalTab] = useState<'articles' | 'authors'>('articles') + setTab = setLocalTab + + return tab === 'articles' + ? ( + + {article =>
} +
+ ) + : ( + + {author =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'article-article-1')).not.toBeNull() + }) + + act(() => setTab('authors')) + + await waitFor(() => { + expect(queryByTestId(container, 'author-author-1')).not.toBeNull() + }) + expect(getByTestId(container, 'author-author-1').textContent).toBe('John Doe') + expect(requestedFields['Author']).not.toContain('title') + }) }) From 63ee3b03c8730d8163b4c22a217110414688928c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 11:06:54 +0100 Subject: [PATCH 5/7] fix(bindx-react): keep a cached selection intact when it is reused under new props An equal query under another entity, depsKey or registry now returns a new cache object instead of rewriting the shared one. A skipped compiled walk passes no source, so a new children identity does not re-run it. Covers a by change with a test that the selection starts afresh. --- .../bindx-react/src/hooks/useRootSelection.ts | 2 +- .../src/hooks/useSelectionCollection.ts | 16 ++++-- .../jsx/entityBranchSwitchSelection.test.tsx | 55 +++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts index 490bd3b9..06399278 100644 --- a/packages/bindx-react/src/hooks/useRootSelection.ts +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -80,7 +80,7 @@ export function useRootSelection(params: UseRootSelectionParams): SelectionColle const runtimeResult = useSelectionCollection({ entityType, depsKey: isWalkSkipped ? `${depsKey}::compiled` : depsKey, - source: children, + source: isWalkSkipped ? undefined : children, // With a compiled selection the walk only feeds the validate-mode diff, which must // compare against what these children read, not a union with earlier ones. grow: compiledResult === null, diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index 83221ea0..65a197e3 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -97,8 +97,8 @@ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): * 4. Merges both sources of selection * * Selection is collected once per `depsKey` and `source` and, unless `grow` is off, - * only grows for one entity type and `depsKey`. Re-renders driven by the store keep the same `source`, so they do not - * re-run the collection. + * only grows for one entity type, `depsKey` and schema registry. Re-renders driven by + * the store keep the same `source`, so they do not re-run the collection. * * **Limitation:** Fields accessed conditionally in the collect function are only * captured if they are accessed when the collection runs. @@ -175,10 +175,14 @@ export function useSelectionCollection( // If the selection hasn't actually changed, return cached values if (cache && cache.queryKey === newQueryKey) { - cache.entityType = entityType - cache.depsKey = depsKey - cache.schemaRegistry = schemaRegistry - return cache + if (cache.entityType === entityType && cache.depsKey === depsKey && cache.schemaRegistry === schemaRegistry) { + return cache + } + // Same query under another entity, depsKey or registry: keep the selection and + // record what it belongs to now, in a new object so the previous result stays intact. + const rekeyed: SelectionCache = { ...cache, entityType, depsKey, schemaRegistry } + selectionCacheRef.current = rekeyed + return rekeyed } // Update cache with new values diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index 7bc493f5..a09b03eb 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -231,4 +231,59 @@ describe(' reused across conditional branches', () => { expect(getByTestId(container, 'author-author-1').textContent).toBe('John Doe') expect(requestedFields['Author']).not.toContain('title') }) + + test('should start the selection afresh when switches to another record', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + const requestedFields: Record = {} + adapter.query = (queries, options) => { + for (const entry of queries) { + if (entry.type === 'get') { + requestedFields[JSON.stringify(entry.by)] = entry.spec.fields.map(field => field.sourcePath[0] ?? field.name) + } + } + return query(queries, options) + } + + let setArticleId: (id: string) => void = () => {} + + function App() { + const [articleId, setLocalArticleId] = useState('article-1') + setArticleId = setLocalArticleId + + return articleId === 'article-1' + ? ( + + {article => ( +
+ + +
+ )} +
+ ) + : ( + + {article =>
} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'article').textContent).toBe('Hello WorldThis is the content') + }) + + act(() => setArticleId('article-2')) + + await waitFor(() => { + expect(getByTestId(container, 'article').textContent).toBe('Another Article') + }) + expect(requestedFields[JSON.stringify({ id: 'article-2' })]).not.toContain('content') + }) }) From dcab4568e0b17774104a79a8d4c5ceb747b31fee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 13:34:29 +0100 Subject: [PATCH 6/7] fix(bindx-react): replace a has-many subquery whose parameters change instead of growing gives each filter/orderBy/limit/offset its own alias, so under the grow-only rule a search or page held in parent state added a subquery on every change. A cached has-many alias of a field the collected children read under other parameters is now dropped before the merge. The covered check also compares the relation flags, and the docs spell out the nested-Entity collection cost and that a compiled selection does not grow. --- .../src/hooks/useSelectionCollection.ts | 50 +++++++-- .../jsx/entityBranchSwitchSelection.test.tsx | 102 +++++++++++++++++- 2 files changed, 145 insertions(+), 7 deletions(-) diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index 65a197e3..8e6cc7cc 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -68,9 +68,35 @@ function cloneSelection(selection: SelectionMeta): SelectionMeta { return { fields } } +/** + * Drops from `target` the has-many entries that `collected` replaces: a field the + * collected children read under other parameters (`` gives each filter / + * orderBy / limit / offset its own alias). A search or a page held in parent state + * then swaps its subquery instead of piling up every earlier one. Fields only the + * cached side reads are kept. Recurses into relations both sides read. + */ +function dropReplacedHasManyAliases(target: SelectionMeta, collected: SelectionMeta): void { + const collectedFieldNames = new Set() + for (const field of collected.fields.values()) { + collectedFieldNames.add(field.fieldName) + } + for (const [key, field] of target.fields) { + const collectedField = collected.fields.get(key) + if (collectedField) { + if (field.nested && collectedField.nested) { + dropReplacedHasManyAliases(field.nested, collectedField.nested) + } + continue + } + if (field.isArray && collectedFieldNames.has(field.fieldName)) { + target.fields.delete(key) + } + } +} + /** * Whether every field `selection` requests is already requested by `covering`, - * recursively and with the same has-many parameters. + * recursively, with the same relation kind and has-many parameters. */ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): boolean { for (const [key, field] of selection.fields) { @@ -78,6 +104,9 @@ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): if (!existing || existing.fieldName !== field.fieldName) { return false } + if ((field.isRelation && !existing.isRelation) || (field.isArray && !existing.isArray)) { + return false + } if (field.hasManyParams && JSON.stringify(field.hasManyParams) !== JSON.stringify(existing.hasManyParams)) { return false } @@ -97,8 +126,15 @@ function isSelectionCovered(selection: SelectionMeta, covering: SelectionMeta): * 4. Merges both sources of selection * * Selection is collected once per `depsKey` and `source` and, unless `grow` is off, - * only grows for one entity type, `depsKey` and schema registry. Re-renders driven by - * the store keep the same `source`, so they do not re-run the collection. + * only grows for one entity type, `depsKey` and schema registry; a has-many read under + * new parameters replaces its earlier subquery. Re-renders driven by this entity's own + * store subscription keep the same `source`, so they do not re-run the collection. An + * nested in another entity's children gets a new inline `children` whenever the + * outer one re-renders — on every store change it subscribes to — and collects again each + * time; the result stays cached, so there is no refetch, only the walk. + * + * A compiled selection (`useRootSelection`) does not grow: it is replaced on a branch + * switch and can shrink. * * **Limitation:** Fields accessed conditionally in the collect function are only * captured if they are accessed when the collection runs. @@ -142,9 +178,10 @@ export function useSelectionCollection( const collected = scope.toSelectionMeta() mergeSelections(collected, jsxSel) - // Grow, never shrink, for one entity type and depsKey: children that stop reading a field - // (a hidden section, a switched branch) must not trigger a refetch, and coming - // back to them must find the field still fetched. + // Grow, never shrink, for one entity type and depsKey: children that stop reading a + // field (a hidden section, a switched branch) must not trigger a refetch, and coming + // back to them must find the field still fetched. A has-many read under new + // parameters replaces its earlier subquery rather than adding to it. const cache = selectionCacheRef.current const growsCache = grow && cache !== null @@ -158,6 +195,7 @@ export function useSelectionCollection( // and the cached one with useEntity, so the union is built in a fresh copy. const selection = growsCache ? cloneSelection(cache.selection) : collected if (growsCache) { + dropReplacedHasManyAliases(selection, collected) mergeSelections(selection, collected) } diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index a09b03eb..980bd22e 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -3,7 +3,7 @@ import '../../setup' import { describe, test, expect, afterEach } from 'bun:test' import { render, waitFor, cleanup, act } from '@testing-library/react' import React, { useState } from 'react' -import { BindxProvider, MockAdapter, Entity, EntityList, Field } from '@contember/bindx-react' +import { BindxProvider, MockAdapter, Entity, EntityList, Field, HasMany } from '@contember/bindx-react' import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../shared' afterEach(() => { @@ -286,4 +286,104 @@ describe(' reused across conditional branches', () => { }) expect(requestedFields[JSON.stringify({ id: 'article-2' })]).not.toContain('content') }) + + test('should replace a has-many subquery when its filter changes instead of adding another', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + const tagSubqueries: number[] = [] + adapter.query = (queries, options) => { + for (const entry of queries) { + if (entry.type === 'get') { + tagSubqueries.push(entry.spec.fields.filter(field => field.sourcePath[0] === 'tags').length) + } + } + return query(queries, options) + } + + let setSearch: (search: string) => void = () => {} + + function App() { + const [search, setLocalSearch] = useState('Java') + setSearch = setLocalSearch + + return ( + + {article => ( +
+ + {tag => } + +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'tags')).not.toBeNull() + }) + + for (const search of ['Re', 'Rea', 'React']) { + act(() => setSearch(search)) + await new Promise(resolve => setTimeout(resolve, 10)) + } + + expect(tagSubqueries.length).toBeGreaterThan(1) + expect(tagSubqueries.every(count => count === tagSubqueries[0])).toBe(true) + }) + + test('should not refetch a nested when the outer entity re-renders on a store change', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + let queryCount = 0 + adapter.query = (...args) => { + queryCount++ + return query(...args) + } + + let setOuterTitle: (title: string) => void = () => {} + + function App() { + return ( + + {article => { + setOuterTitle = title => article.title.setValue(title) + return ( +
+
+ + {inner =>
} +
+
+ ) + }} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'inner')).not.toBeNull() + }) + const queriesAfterLoad = queryCount + + act(() => setOuterTitle('Edited')) + act(() => setOuterTitle('Edited again')) + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(getByTestId(container, 'outer').textContent).toBe('Edited again') + expect(getByTestId(container, 'inner').textContent).toBe('More content here') + expect(queryCount).toBe(queriesAfterLoad) + }) }) From 65f443af903569abd891c0788b7d91160a71242d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 13:40:12 +0100 Subject: [PATCH 7/7] fix(bindx-react): drop a cached has-many alias only when the children bring a new one for its field Any read of the same field, including the plain one the collector records, counted as a replacement, so unrelated growth dropped a filtered list that was only hidden. Aliases are now dropped only when the collected children read the field under parameters the cache has not seen, and not at all when the JSX analysis failed. The filter test also checks that the newest alias is the one fetched. --- .../src/hooks/useSelectionCollection.ts | 30 ++++++--- .../jsx/entityBranchSwitchSelection.test.tsx | 65 ++++++++++++++++++- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/bindx-react/src/hooks/useSelectionCollection.ts b/packages/bindx-react/src/hooks/useSelectionCollection.ts index 8e6cc7cc..048e0565 100644 --- a/packages/bindx-react/src/hooks/useSelectionCollection.ts +++ b/packages/bindx-react/src/hooks/useSelectionCollection.ts @@ -69,16 +69,20 @@ function cloneSelection(selection: SelectionMeta): SelectionMeta { } /** - * Drops from `target` the has-many entries that `collected` replaces: a field the - * collected children read under other parameters (`` gives each filter / - * orderBy / limit / offset its own alias). A search or a page held in parent state - * then swaps its subquery instead of piling up every earlier one. Fields only the - * cached side reads are kept. Recurses into relations both sides read. + * Drops from `target` the has-many entries that `collected` replaces. `` gives + * each filter / orderBy / limit / offset its own alias, so a field the collected children + * read under parameters `target` has not seen yet is a replacement: the cached + * parameterised aliases of that field that the children no longer read go, and a search + * or a page held in parent state swaps its subquery instead of piling up every earlier + * one. Without such a new alias nothing is dropped, so a hidden filtered list survives + * growth elsewhere. Recurses into relations both sides read. */ function dropReplacedHasManyAliases(target: SelectionMeta, collected: SelectionMeta): void { - const collectedFieldNames = new Set() - for (const field of collected.fields.values()) { - collectedFieldNames.add(field.fieldName) + const replacedFieldNames = new Set() + for (const [key, field] of collected.fields) { + if (field.isArray && field.hasManyParams && key !== field.fieldName && !target.fields.has(key)) { + replacedFieldNames.add(field.fieldName) + } } for (const [key, field] of target.fields) { const collectedField = collected.fields.get(key) @@ -88,7 +92,7 @@ function dropReplacedHasManyAliases(target: SelectionMeta, collected: SelectionM } continue } - if (field.isArray && collectedFieldNames.has(field.fieldName)) { + if (field.isArray && key !== field.fieldName && replacedFieldNames.has(field.fieldName)) { target.fields.delete(key) } } @@ -167,11 +171,13 @@ export function useSelectionCollection( // Analyze the returned JSX for component-level selections let jsxSel: SelectionMeta + let jsxAnalysed = true try { jsxSel = collectSelection(jsx) } catch { // staticRender of nested components may crash during collection jsxSel = { fields: new Map() } + jsxAnalysed = false } // Convert scope to SelectionMeta and merge with JSX selection @@ -195,7 +201,11 @@ export function useSelectionCollection( // and the cached one with useEntity, so the union is built in a fresh copy. const selection = growsCache ? cloneSelection(cache.selection) : collected if (growsCache) { - dropReplacedHasManyAliases(selection, collected) + // A failed JSX analysis misses the aliased has-manys the children render, so + // nothing cached may be treated as replaced. + if (jsxAnalysed) { + dropReplacedHasManyAliases(selection, collected) + } mergeSelections(selection, collected) } diff --git a/tests/react/jsx/entityBranchSwitchSelection.test.tsx b/tests/react/jsx/entityBranchSwitchSelection.test.tsx index 980bd22e..9e63a175 100644 --- a/tests/react/jsx/entityBranchSwitchSelection.test.tsx +++ b/tests/react/jsx/entityBranchSwitchSelection.test.tsx @@ -291,10 +291,13 @@ describe(' reused across conditional branches', () => { const adapter = new MockAdapter(createMockData(), { delay: 0 }) const query = adapter.query.bind(adapter) const tagSubqueries: number[] = [] + let lastTagFilters: unknown[] = [] adapter.query = (queries, options) => { for (const entry of queries) { if (entry.type === 'get') { - tagSubqueries.push(entry.spec.fields.filter(field => field.sourcePath[0] === 'tags').length) + const tagFields = entry.spec.fields.filter(field => field.sourcePath[0] === 'tags') + tagSubqueries.push(tagFields.length) + lastTagFilters = tagFields.map(field => field.filter).filter(filter => filter !== undefined) } } return query(queries, options) @@ -336,6 +339,7 @@ describe(' reused across conditional branches', () => { expect(tagSubqueries.length).toBeGreaterThan(1) expect(tagSubqueries.every(count => count === tagSubqueries[0])).toBe(true) + expect(lastTagFilters).toEqual([{ name: { containsCI: 'React' } }]) }) test('should not refetch a nested when the outer entity re-renders on a store change', async () => { @@ -386,4 +390,63 @@ describe(' reused across conditional branches', () => { expect(getByTestId(container, 'inner').textContent).toBe('More content here') expect(queryCount).toBe(queriesAfterLoad) }) + + test('should keep a hidden filtered has-many when the selection grows elsewhere', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const query = adapter.query.bind(adapter) + let queryCount = 0 + adapter.query = (...args) => { + queryCount++ + return query(...args) + } + + let setShowFiltered: (show: boolean) => void = () => {} + let setShowTitle: (show: boolean) => void = () => {} + + function App() { + const [showFiltered, setLocalShowFiltered] = useState(true) + const [showTitle, setLocalShowTitle] = useState(false) + setShowFiltered = setLocalShowFiltered + setShowTitle = setLocalShowTitle + + return ( + + {article => ( +
+ {tag => } + {showFiltered && ( +
+ {tag => } +
+ )} + {showTitle &&
} +
+ )} +
+ ) + } + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'filtered')).not.toBeNull() + }) + + act(() => setShowFiltered(false)) + act(() => setShowTitle(true)) + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + const queriesAfterGrowth = queryCount + + act(() => setShowFiltered(true)) + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(queryCount).toBe(queriesAfterGrowth) + expect(getByTestId(container, 'filtered').textContent).not.toBe('') + }) })