Skip to content
4 changes: 4 additions & 0 deletions packages/bindx-react/src/hooks/useRootSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export function useRootSelection(params: UseRootSelectionParams): SelectionColle
const runtimeResult = useSelectionCollection({
entityType,
depsKey: isWalkSkipped ? `${depsKey}::compiled` : depsKey,
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,
collect: collector => {
if (isWalkSkipped) {
return null
Expand Down
150 changes: 139 additions & 11 deletions packages/bindx-react/src/hooks/useSelectionCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -17,6 +17,22 @@ export interface UseSelectionCollectionOptions {
depsKey: string
/** Collector function — receives a collector proxy, returns JSX for analysis */
collect: (collector: EntityAccessor<unknown>) => ReactNode
/**
* The user-supplied render function `collect` wraps. A new identity re-runs the
* collection: React reuses an <Entity> across conditional branches that differ only
* in their children, and those children may read fields the previous ones did not.
* 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<string, unknown>
}
Expand All @@ -37,6 +53,72 @@ export interface SelectionCollectionResult {
interface SelectionCache {
selection: SelectionMeta
queryKey: string
/** 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. */
function cloneSelection(selection: SelectionMeta): SelectionMeta {
const fields = new Map<string, SelectionFieldMeta>()
for (const [key, field] of selection.fields) {
fields.set(key, field.nested ? { ...field, nested: cloneSelection(field.nested) } : { ...field })
}
return { fields }
}

/**
* Drops from `target` the has-many entries that `collected` replaces. `<HasMany>` 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 replacedFieldNames = new Set<string>()
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)
if (collectedField) {
if (field.nested && collectedField.nested) {
dropReplacedHasManyAliases(field.nested, collectedField.nested)
}
continue
}
if (field.isArray && key !== field.fieldName && replacedFieldNames.has(field.fieldName)) {
target.fields.delete(key)
}
}
}

/**
* Whether every field `selection` requests is already requested by `covering`,
* recursively, with the same relation kind and 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.isRelation && !existing.isRelation) || (field.isArray && !existing.isArray)) {
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
}

/**
Expand All @@ -47,19 +129,29 @@ 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` and, unless `grow` is off,
* 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
* <Entity> 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.
*
* @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, grow = true, 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

Expand All @@ -79,16 +171,43 @@ 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
const selection = scope.toSelectionMeta()
mergeSelections(selection, jsxSel)
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. 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
&& cache.entityType === entityType
&& cache.depsKey === depsKey
&& cache.schemaRegistry === schemaRegistry
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) {
// 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)
}

// Debug output can be enabled via the debug prop on BindxProvider
if (bindxContext?.debug) {
Expand All @@ -103,20 +222,29 @@ 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) {
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
const newCache: SelectionCache = {
selection,
queryKey: newQueryKey,
entityType,
depsKey,
schemaRegistry,
}
selectionCacheRef.current = newCache

return newCache
}, [entityType, depsKey, schemaRegistry]) // Only depend on entity identity, deps key, and schema
}, [entityType, depsKey, source, grow, schemaRegistry])

return result
}
4 changes: 3 additions & 1 deletion packages/bindx-react/src/jsx/components/EntityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export interface EntityListProps<TRoleMap extends Record<string, object> = 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 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
*
Expand Down Expand Up @@ -79,6 +80,7 @@ function EntityListComponent<TRoleMap extends Record<string, object>>({
const { selection, queryKey: selectionQueryKey } = useSelectionCollection({
entityType,
depsKey: optionsKey,
source: children,
collect: collector => children(collector as unknown as EntityAccessor<CommonEntity<TRoleMap>>, 0),
queryKeyExtra: { filter, orderBy, limit, offset },
})
Expand Down
Loading
Loading