Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -188,11 +192,14 @@ 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])
// 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 ---
const listStateRef = useRef<{
Expand Down
118 changes: 118 additions & 0 deletions tests/react/hooks/useEntityList/selectionIdentityRefetchLoop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Regression test for <issue-url filled in after the issue is created>
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<TestSchema>({
entities: {
Author: {
fields: {
id: scalar(),
name: scalar(),
email: scalar(),
},
},
},
})

const authorDef = entityDef<Author>('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<typeof originalQuery>) => {
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<Author, Author>(a => a.id().name().email()),
[selectionVersion],
)
const list = useEntityList(authorDef, {
selection,
queryKey: 'stable-author-query-key',
})
if (list.$status !== 'ready') return <div data-testid="loading">Loading…</div>
return <div data-testid="count">{list.length}</div>
}

const { container, rerender } = render(
<BindxProvider adapter={adapter} schema={schema}>
<GridLike selectionVersion={0} />
</BindxProvider>,
)

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(
<BindxProvider adapter={adapter} schema={schema}>
<GridLike selectionVersion={1} />
</BindxProvider>,
)

// 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)
})
})