From d09b76c9c681d5bda9c806bb9995f6e92c8e9f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 14:25:37 +0100 Subject: [PATCH 1/4] fix(bindx-react): fetch and render a parameterised HasMany under its own alias A with filter, orderBy, limit or offset collected its children through the collector's map(), which wrote them into the plain relation as well. The query therefore carried the whole relation with the children's fields next to the parameterised subquery, and at render the HasMany read the plain relation: the list it showed ignored its parameters. SelectionScope now keeps a has-many read with parameters as its own child keyed by the params alias, and the collector hands an item over that child, in every collection path including a component's implicit one. At render, HasMany resolves its params alias through the parent entity (HAS_MANY_ALIAS_RESOLVER) and reads the data fetched under it. The compiled field-map format has no params, so the compiler now bails on any parameterised (HASMANY_PARAMS), not only in an root. Fixes #158 --- docs/compiler-plan.md | 4 +- docs/selection-collection.md | 2 +- .../src/selection/SelectionScope.ts | 51 ++++++-- packages/bindx-compiler/src/analyze.ts | 2 +- packages/bindx-compiler/src/body.ts | 3 +- packages/bindx-compiler/src/entityRoots.ts | 2 +- packages/bindx-compiler/src/jsx.ts | 10 +- packages/bindx-compiler/src/types.ts | 2 +- .../bindx-compiler/tests/analyzer.test.ts | 21 +--- .../tests/fixtures/hasManyParams.tsx | 7 +- packages/bindx-compiler/tests/plugin.test.ts | 3 +- .../bindx-react/src/jsx/collectorProxy.ts | 8 +- .../src/jsx/components/HasMany.tsx | 61 +++++++--- packages/bindx-react/src/jsx/types.ts | 16 +++ packages/bindx/src/handles/EntityHandle.ts | 1 + .../bindx/src/handles/HasManyListHandle.ts | 27 ++++- packages/bindx/src/handles/index.ts | 2 +- packages/bindx/src/index.ts | 2 +- ...dHasManyFetchesUnfilteredRelation.test.tsx | 113 ++++++++++++++++++ tests/selectionCollection.test.tsx | 4 +- 20 files changed, 274 insertions(+), 67 deletions(-) create mode 100644 tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index dae232c8..f0f61aa9 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -65,8 +65,8 @@ type StaticFieldNode = assumesNotHasOne?: true // a ref API value (`.value`, `.errors`) left the analyzed code; // the runtime falls back when the schema says has-one } -// Has-many params are not part of the format: the runtime collector ignores them inside a -// component, and an root with a parameterised bails (HASMANY_PARAMS_AT_ROOT). +// Has-many params are not part of the format: a parameterised is collected and +// rendered under its params alias, so the component or root holding it bails (HASMANY_PARAMS). /** Emitted as the 2nd argument of .render(): key = implicit entity prop name. */ type StaticSelection = Record diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 6cc53dd0..261c2092 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -461,7 +461,7 @@ fields) is acceptable; under-approximation is impossible by construction (defaul deny). Anything it cannot classify makes the whole component **bail** with a machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PROP`, `FUNCTION_PROP_ON_HOLE`, `RENDER_LOCAL_ON_HOLE`, `ENTITY_REASSIGNMENT`, `ENTITY_SPREAD`, -`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `ENTITY_ESCAPES_TO_VALUE`, `HASMANY_PARAMS_AT_ROOT`, `INTERFACES_MODE`, +`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `ENTITY_ESCAPES_TO_VALUE`, `HASMANY_PARAMS`, `INTERFACES_MODE`, `UNCLASSIFIED`). Bailed chains are left untouched and fall back to the runtime proxy pass. `FUNCTION_PROP_ON_HOLE` / `RENDER_LOCAL_ON_HOLE` guard an under-fetch class: a hole element's diff --git a/packages/bindx-client/src/selection/SelectionScope.ts b/packages/bindx-client/src/selection/SelectionScope.ts index d02885b8..ca430ff2 100644 --- a/packages/bindx-client/src/selection/SelectionScope.ts +++ b/packages/bindx-client/src/selection/SelectionScope.ts @@ -16,6 +16,8 @@ export interface HasManyParams { * Relation metadata stored at parent level */ interface RelationMeta { + /** The schema field; the child's key differs from it for a has-many keyed by its params alias. */ + fieldName: string isArray: boolean params?: HasManyParams } @@ -57,7 +59,31 @@ export class SelectionScope { childScope.parent = this childScope.addScalar('id') this.children.set(fieldName, childScope) - this.relationMeta.set(fieldName, { isArray: false }) + this.relationMeta.set(fieldName, { fieldName, isArray: false }) + } + return childScope + } + + /** + * Create or get the child scope of a has-many read with parameters. It is keyed by the + * params alias (`generateHasManyAlias`), next to and independent of the plain relation's + * child, so a filtered or paginated list selects its fields only under its own alias and + * two lists over the same field with different parameters stay apart. + */ + hasManyChild(fieldName: string, params: HasManyParams): SelectionScope { + const alias = generateHasManyAlias(fieldName, params) + if (alias === fieldName) { + const plain = this.child(fieldName) + this.markAsArray(fieldName) + return plain + } + let childScope = this.children.get(alias) + if (!childScope) { + childScope = new SelectionScope() + childScope.parent = this + childScope.addScalar('id') + this.children.set(alias, childScope) + this.relationMeta.set(alias, { fieldName, isArray: true, params }) } return childScope } @@ -110,14 +136,18 @@ export class SelectionScope { } // Merge relation children - for (const [fieldName, otherChild] of other.children) { - const thisChild = this.child(fieldName) + for (const [key, otherChild] of other.children) { + const otherMeta = other.relationMeta.get(key) + if (otherMeta && key !== otherMeta.fieldName && otherMeta.params) { + this.hasManyChild(otherMeta.fieldName, otherMeta.params).merge(otherChild) + continue + } + const thisChild = this.child(key) thisChild.merge(otherChild) // Merge relation metadata - const otherMeta = other.relationMeta.get(fieldName) if (otherMeta) { - const thisMeta = this.relationMeta.get(fieldName)! + const thisMeta = this.relationMeta.get(key)! if (otherMeta.isArray) { thisMeta.isArray = true } @@ -137,7 +167,9 @@ export class SelectionScope { // Only process root-level fields if (field.path.length !== 1) continue - if (field.isRelation && field.nested) { + if (field.isRelation && field.nested && field.isArray && field.hasManyParams && field.alias !== field.fieldName) { + this.hasManyChild(field.fieldName, field.hasManyParams).mergeFromSelectionMeta(field.nested) + } else if (field.isRelation && field.nested) { const childScope = this.child(field.fieldName) if (field.isArray) { this.markAsArray(field.fieldName) @@ -174,13 +206,14 @@ export class SelectionScope { } // Add relation fields with nested selections - for (const [fieldName, childScope] of this.children) { - const meta = this.relationMeta.get(fieldName)! + for (const [key, childScope] of this.children) { + const meta = this.relationMeta.get(key)! + const fieldName = meta.fieldName // Generate alias for has-many fields with params const alias = meta.isArray && meta.params ? generateHasManyAlias(fieldName, meta.params) - : fieldName + : key fields.set(alias, { fieldName, diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index abf194fe..fe4bd4d4 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -104,7 +104,7 @@ function analyzeChain( return { loc, bailout: chain.earlyBail } } const propRoots = new Map(chain.entityProps.map(prop => [prop, new SelNode()])) - const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds, false) + const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds) try { if (chain.conditionFn) { analyzer.analyzeFunction(chain.conditionFn, propRoots) diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index 85ba5e58..f182906c 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -33,9 +33,8 @@ export class BodyAnalyzer { moduleScope: ModuleScope, contracts: ContractLookup, targetKinds: TargetKindLookup, - isSelectionRoot: boolean, ) { - this.jsx = new JsxAnalyzer(this, bindings, moduleScope, contracts, targetKinds, isSelectionRoot) + this.jsx = new JsxAnalyzer(this, bindings, moduleScope, contracts, targetKinds) } /** Public so JsxAnalyzer can register a hole it discovered. */ diff --git a/packages/bindx-compiler/src/entityRoots.ts b/packages/bindx-compiler/src/entityRoots.ts index cafb36b2..3ce2bb32 100644 --- a/packages/bindx-compiler/src/entityRoots.ts +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -113,7 +113,7 @@ export function analyzeEntityRoot( } const rootNode = new SelNode() - const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds, true) + const analyzer = new BodyAnalyzer(bindings, moduleScope, lookup, targetKinds) try { analyzer.analyzeRootChildren(childrenFn, rootNode, ENTITY_ROOT_KEY) } catch (error) { diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index e5839aac..e9fcce1d 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -42,8 +42,6 @@ export class JsxAnalyzer { private readonly moduleScope: ModuleScope, private readonly contracts: ContractLookup, private readonly targetKinds: TargetKindLookup, - /** Analyzing an `` root: the runtime walk there also keeps what components return. */ - private readonly isSelectionRoot: boolean, ) {} walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { @@ -392,10 +390,10 @@ export class JsxAnalyzer { } return // no entity reaches this element → nothing it collects belongs to this selection } - if (many && this.isSelectionRoot && HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null)) { - // The root walk keeps 's returned, param-aliased relation next to the plain one; - // a field map holds one node per field. Inside a component the runtime ignores params. - throw new BailError({ code: 'HASMANY_PARAMS_AT_ROOT', message: ' with params in an root' }) + if (many && HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null)) { + // The runtime collects a parameterised under its params alias, next to the + // plain relation, and renders that alias; a field map holds one node per field. + throw new BailError({ code: 'HASMANY_PARAMS', message: ' with params' }) } const item = many ? consumeMany(res.ref) : consumeRelation(res.ref) const children = childrenSlot(node) diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index 29742769..134a5e8d 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -67,7 +67,7 @@ export type BailoutReason = | 'MEMBER_COMPONENT_TAG' | 'ENTITY_SPREAD' | 'COMPUTED_MEMBER' - | 'HASMANY_PARAMS_AT_ROOT' + | 'HASMANY_PARAMS' | 'ENTITY_ESCAPES_TO_VALUE' | 'ENTITY_REASSIGNMENT' | 'ENTITY_NO_FUNCTION_CHILDREN' diff --git a/packages/bindx-compiler/tests/analyzer.test.ts b/packages/bindx-compiler/tests/analyzer.test.ts index 72f264ba..7cd322c2 100644 --- a/packages/bindx-compiler/tests/analyzer.test.ts +++ b/packages/bindx-compiler/tests/analyzer.test.ts @@ -19,21 +19,10 @@ function selectionOf(code: string): Record { const PRELUDE = `import { createComponent, Field, HasMany, HasOne } from '@contember/bindx-react'\nimport { schema } from './s'\n` describe('StaticSelection emission (raw, not normalized)', () => { - test('has-many params are not emitted (the runtime ignores them inside a component)', () => { + test('a component with a parameterised bails (the runtime collects it under its params alias)', () => { const results = analyzeFixture(DIR, 'hasManyParams.tsx') const result = results[0]! - expect(isBailed(result)).toBe(false) - if (isBailed(result)) { - return - } - expect(result.selection).toEqual({ - article: { - tags: { - fields: { id: true, name: true, color: true }, - many: true, - }, - }, - }) + expect(isBailed(result) ? result.bailout.code : null).toBe('HASMANY_PARAMS') }) test('scalar leaf is `true`, has-one relation nests with id', () => { @@ -88,9 +77,9 @@ describe('bail triggers (direct)', () => { expect(bail(`({ article }) => `)).toBe('COMPUTED_MEMBER') }) - test('HasMany params inside a component are ignored, like the runtime collector', () => { + test('HasMany params inside a component', () => { expect(bail(`({ article }) => {t => }`)) - .toBeNull() + .toBe('HASMANY_PARAMS') }) test('HasMany params in an root', () => { @@ -100,7 +89,7 @@ describe('bail triggers (direct)', () => { + `{t => }}`, 'root.tsx', ) - expect(root && isEntityRootBailed(root) ? root.bailout.code : null).toBe('HASMANY_PARAMS_AT_ROOT') + expect(root && isEntityRootBailed(root) ? root.bailout.code : null).toBe('HASMANY_PARAMS') }) }) diff --git a/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx index 2a555f7a..78b60e08 100644 --- a/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx +++ b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx @@ -2,9 +2,8 @@ import { createComponent, Field, HasMany } from '@contember/bindx-react' import type { FixtureCase } from '../fixtureTypes.js' import { schema } from './_schema.js' -// Has-many with literal params. The runtime collector drops params in the implicit -// path (verified against the oracle), so field-tree equality holds; params are -// asserted separately in analyzer.test.ts. +// Has-many with literal params. The runtime collects it under its params alias, which the +// field-map format cannot hold, so the chain bails to the runtime pass. export const HasManyParams = createComponent() .entity('article', schema.Article) .render(({ article }) => ( @@ -19,5 +18,5 @@ export const HasManyParams = createComponent() )) export const cases: FixtureCase[] = [ - { component: HasManyParams, prop: 'article' }, + { component: HasManyParams, prop: 'article', expect: 'bail', code: 'HASMANY_PARAMS' }, ] diff --git a/packages/bindx-compiler/tests/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts index 98cc488b..8ec95d4f 100644 --- a/packages/bindx-compiler/tests/plugin.test.ts +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -25,7 +25,7 @@ export const Card = createComponent()
{a => } - {tag => } + {tag => }
)) ` @@ -41,7 +41,6 @@ describe('babel plugin injection', () => { expect(output).toContain('author: {') expect(output).toContain('fields: {') expect(output).toContain('many: true') - expect(output).not.toContain('params:') // the runtime ignores params inside a component // This chain has no holes → the holes key is omitted. expect(output).not.toContain('holes:') }) diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 4832b4c2..ca60f2eb 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -1,5 +1,5 @@ import type { SchemaRegistry } from '@contember/bindx' -import { SelectionScope, generatePlaceholderId } from '@contember/bindx' +import { SelectionScope, generatePlaceholderId, type HasManyParams } from '@contember/bindx' import { type EntityAccessor, type EntityFields, @@ -11,6 +11,8 @@ import { type HasOneAccessor, FIELD_REF_META, SCOPE_REF, + HAS_MANY_PARAMS_COLLECTOR, + type HasManyParamsCollector, } from './types.js' import { wrapEntityRefWithFieldAccessProxy, REACT_ELEMENT_PROBE_KEYS } from './proxyShared.js' @@ -184,6 +186,10 @@ function createCollectorFieldRef( get [SCOPE_REF](): SelectionScope { return getChildScope() }, + [HAS_MANY_PARAMS_COLLECTOR]: (params: HasManyParams): HasManyParamsCollector => { + const scope = parentScope.hasManyChild(fieldName, params) + return { item: createCollectorProxy(scope, targetEntityName, schemaRegistry), scope } + }, // FieldRef properties (non-$ versions) value: null, diff --git a/packages/bindx-react/src/jsx/components/HasMany.tsx b/packages/bindx-react/src/jsx/components/HasMany.tsx index 0ce5c93f..ba6445d8 100644 --- a/packages/bindx-react/src/jsx/components/HasMany.tsx +++ b/packages/bindx-react/src/jsx/components/HasMany.tsx @@ -1,8 +1,16 @@ import React, { memo, type ReactElement, type ReactNode } from 'react' import type { HasManyProps, SelectionFieldMeta, SelectionMeta, SelectionProvider, AnyBrand } from '../types.js' -import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js' +import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF, HAS_MANY_PARAMS_COLLECTOR, type HasManyParamsCollector } from '../types.js' import { mergeSelections } from '../SelectionMeta.js' -import { SelectionScope, generateHasManyAlias, type HasManyAccessor } from '@contember/bindx' +import { + SelectionScope, + generateHasManyAlias, + HAS_MANY_ALIAS_RESOLVER, + type HasManyAccessor, + type HasManyAliasResolver, + type HasManyParams, + type HasManyRef, +} from '@contember/bindx' import { useHasMany } from '../../hooks/useHasMany.js' import { annotateElement } from '../devAnnotations.js' @@ -36,9 +44,18 @@ function HasManyImpl< TBrand extends AnyBrand = AnyBrand, TEntityName extends string = string, TSchema extends Record = Record, ->({ field, children }: HasManyProps): ReactElement { +>({ field, children, filter, orderBy, limit, offset }: HasManyProps): ReactElement { + // With parameters the data was fetched under the params alias (see getSelection); the + // field the children received reads the plain relation, so switch to that alias. + const fieldName = field[FIELD_REF_META].fieldName + const alias = generateHasManyAlias(fieldName, { filter, orderBy, limit, offset }) + const resolveAlias = (field as unknown as { [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver })[HAS_MANY_ALIAS_RESOLVER] + const listRef = alias !== fieldName && resolveAlias + ? resolveAlias(alias) as unknown as HasManyRef + : field + // useHasMany() subscribes to store and returns HasManyAccessor with .map()/.items/.length - const accessor = useHasMany(field) + const accessor = useHasMany(listRef) const targetType = field[FIELD_REF_META]?.targetType const items = accessor.map((item, index) => { @@ -67,22 +84,36 @@ hasManyWithSelection.getSelection = ( // During collection, field is a collector proxy with all accessor properties const fullField = props.field as unknown as HasManyAccessor - // Use field's map function to get a properly configured collector with schema info - // The map() creates a collector that knows about entity types and relations const result: { syntheticChildren: ReactNode; childScope: SelectionScope | null } = { syntheticChildren: null, childScope: null, } - fullField.map((item, index) => { - // Get the scope from the collector entity (it has SCOPE_REF) - const scopeRef = (item as unknown as Record)[SCOPE_REF] - if (scopeRef instanceof SelectionScope) { - result.childScope = scopeRef - } - result.syntheticChildren = props.children(item, index) - return null - }) + // With parameters the children are collected into the parent scope's child for the + // params alias. Collecting through `field.map()` would write them into the plain + // relation, so a filtered or paginated list would fetch the whole relation as well. + // Without parameters the plain relation is this field's own place, and `map()` uses it. + const params = { filter: props.filter, orderBy: props.orderBy, limit: props.limit, offset: props.offset } + const hasParams = params.filter !== undefined || params.orderBy !== undefined || params.limit !== undefined || params.offset !== undefined + const collectWithParams = (props.field as unknown as { [HAS_MANY_PARAMS_COLLECTOR]?: (params: HasManyParams) => HasManyParamsCollector })[ + HAS_MANY_PARAMS_COLLECTOR + ] + if (hasParams && collectWithParams) { + const { item, scope } = collectWithParams(params) + result.childScope = scope + result.syntheticChildren = props.children(item, 0) + } else { + // A field without a detached collector (not a collector proxy): its map() still + // yields a collector item that knows the target entity. + fullField.map((item, index) => { + const scopeRef = (item as unknown as Record)[SCOPE_REF] + if (scopeRef instanceof SelectionScope) { + result.childScope = scopeRef + } + result.syntheticChildren = props.children(item, index) + return null + }) + } // Also analyze the JSX structure const jsxSelection = result.syntheticChildren ? collectNested(result.syntheticChildren) : { fields: new Map() } diff --git a/packages/bindx-react/src/jsx/types.ts b/packages/bindx-react/src/jsx/types.ts index 72d09eef..dd055153 100644 --- a/packages/bindx-react/src/jsx/types.ts +++ b/packages/bindx-react/src/jsx/types.ts @@ -18,6 +18,7 @@ import type { EntityRef, EntityAccessor, AnyBrand, + SelectionScope, } from '@contember/bindx' import { Condition } from './conditions.js' @@ -57,6 +58,21 @@ export const BINDX_COMPONENT = Symbol('BINDX_COMPONENT') */ export const SCOPE_REF = Symbol('SCOPE_REF') +/** + * Symbol for a has-many collector ref's params item collector: given has-many parameters, + * it returns a collector item over the parent scope's child for that params alias + * (`SelectionScope.hasManyChild`). `` collects its children through it, + * so a parameterised list registers its fields only under its own alias and not also on + * the plain, unfiltered relation. + */ +export const HAS_MANY_PARAMS_COLLECTOR = Symbol('HAS_MANY_PARAMS_COLLECTOR') + +/** What a {@link HAS_MANY_PARAMS_COLLECTOR} function returns. */ +export interface HasManyParamsCollector { + readonly item: EntityAccessor + readonly scope: SelectionScope +} + /** * Props for Field component. diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index a9b1289b..6cd20f23 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -372,6 +372,7 @@ export class EntityHandle extends Enti effectiveAlias, nestedSelection, [...this.entityPath, fieldName], + siblingAlias => this.hasMany(fieldName, siblingAlias, this.selection?.fields.get(siblingAlias)?.nested), ) this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle }) diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index f0e9ef9c..b1c87e3f 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -25,6 +25,17 @@ import type { } from '../events/types.js' import { createAliasProxy } from './proxyFactory.js' +/** + * Symbol for a has-many handle's alias resolver: given the alias of the same relation + * selected with other parameters (`generateHasManyAlias`), it returns the handle that + * reads the data fetched under that alias. `` uses it so the list it + * renders is the filtered one, not the plain relation. + */ +export const HAS_MANY_ALIAS_RESOLVER = Symbol('HAS_MANY_ALIAS_RESOLVER') + +/** Resolves a sibling alias of the same has-many relation; see {@link HAS_MANY_ALIAS_RESOLVER}. */ +export type HasManyAliasResolver = (alias: string) => HasManyAccessor + /** * HasManyListHandle provides access to a has-many relation (list of entities). * Implements HasManyRef interface for JSX compatibility. @@ -43,6 +54,9 @@ export class HasManyListHandle + /** Present when the parent entity can hand out this relation under another alias. */ + readonly [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver + /** * Type brand - entity name for type inference. * Implements HasManyRef.__entityName. @@ -80,10 +94,12 @@ export class HasManyListHandle( @@ -98,9 +114,13 @@ export class HasManyListHandle { return createAliasProxy, HasManyAccessor>( - HasManyListHandle.createRaw(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath), + HasManyListHandle.createRaw( + parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, + brands, alias, selection, relationPath, resolveAlias, + ), ) } @@ -120,8 +140,11 @@ export class HasManyListHandle { - return new HasManyListHandle(parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath) + return new HasManyListHandle( + parentEntityType, parentEntityId, fieldName, itemType, store, dispatcher, schema, brands, alias, selection, relationPath, resolveAlias, + ) } /** diff --git a/packages/bindx/src/handles/index.ts b/packages/bindx/src/handles/index.ts index e2fc2c67..2d40dd5e 100644 --- a/packages/bindx/src/handles/index.ts +++ b/packages/bindx/src/handles/index.ts @@ -3,7 +3,7 @@ export { FieldHandle } from './FieldHandle.js' export { EntityHandle } from './EntityHandle.js' export { HasOneHandle } from './HasOneHandle.js' export { PlaceholderHandle } from './PlaceholderHandle.js' -export { HasManyListHandle } from './HasManyListHandle.js' +export { HasManyListHandle, HAS_MANY_ALIAS_RESOLVER, type HasManyAliasResolver } from './HasManyListHandle.js' export { FIELD_REF_META, type HasOneRelationState, diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 0d8e4d9a..e49a5e65 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -169,7 +169,7 @@ export { createFragment, buildQueryFromSelection, SELECTION_META, createSelectio export type { HasManyParams, StaticFieldMap, StaticFieldNode, StaticSelectionSchema } from './selection/index.js' // Handles -export { EntityHandle, HasOneHandle, HasManyListHandle, FieldHandle } from './handles/index.js' +export { EntityHandle, HasOneHandle, HasManyListHandle, FieldHandle, HAS_MANY_ALIAS_RESOLVER, type HasManyAliasResolver } from './handles/index.js' // Handle symbols export { FIELD_REF_META } from './handles/index.js' diff --git a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx new file mode 100644 index 00000000..e0528136 --- /dev/null +++ b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx @@ -0,0 +1,113 @@ +// Regression test for https://github.com/contember/bindx/issues/158 +import '../../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup } from '@testing-library/react' +import React from 'react' +import { BindxProvider, MockAdapter, Entity, Field, HasMany } from '@contember/bindx-react' +import type { QueryFieldSpec } from '@contember/bindx' +import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../../shared' + +afterEach(() => { + cleanup() +}) + +function recordTagFields(adapter: MockAdapter): { current: QueryFieldSpec[] } { + const query = adapter.query.bind(adapter) + const tagFields: { current: QueryFieldSpec[] } = { current: [] } + adapter.query = (queries, options) => { + for (const entry of queries) { + if (entry.type === 'get') { + tagFields.current = entry.spec.fields.filter(field => field.sourcePath[0] === 'tags') + } + } + return query(queries, options) + } + return tagFields +} + +const nestedFieldNames = (field: QueryFieldSpec | undefined): string[] => field?.nested?.fields.map(nested => nested.name) ?? [] + +describe(' with parameters', () => { + test('should fetch the children fields only under the params alias', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const tagFields = recordTagFields(adapter) + + const { container } = render( + + + {article => ( +
+ + {tag => } + +
+ )} +
+
, + ) + + await waitFor(() => { + expect(queryByTestId(container, 'tags')).not.toBeNull() + }) + + const filtered = tagFields.current.find(field => field.filter !== undefined) + const plain = tagFields.current.find(field => field.name === 'tags') + expect(filtered?.filter).toEqual({ name: { eq: 'React' } }) + expect(nestedFieldNames(filtered)).toContain('name') + // Reading `article.tags` registers the relation with its id only; the children's + // fields are not fetched for the whole relation. + expect(nestedFieldNames(plain)).toEqual(['id']) + }) + + test('should render the filtered list, not the whole relation', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {article => ( +
+ + {tag => } + +
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('React') + }) + }) + + test('should render each sibling with its own parameters', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + const { container } = render( + + + {article => ( +
+
+ {tag => } +
+
+ {tag => } +
+
+ {tag => } +
+
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'all').textContent).toBe('JavaScriptReact') + }) + expect(getByTestId(container, 'react').textContent).toBe('React') + expect(getByTestId(container, 'javascript').textContent).toBe('#f7df1e') + }) +}) diff --git a/tests/selectionCollection.test.tsx b/tests/selectionCollection.test.tsx index 7d6f8017..a0146724 100644 --- a/tests/selectionCollection.test.tsx +++ b/tests/selectionCollection.test.tsx @@ -301,8 +301,8 @@ describe('Selection Collection with Schema', () => { // Build query const query = buildQueryFromSelection(selection) - // usages should be in the query - const usagesSpec = query.fields.find((f: any) => f.name === 'usages') + // usages should be in the query, under its orderBy alias + const usagesSpec = query.fields.find((f: any) => f.sourcePath[0] === 'usages' && f.orderBy !== undefined) expect(usagesSpec).toBeDefined() expect(usagesSpec!.nested).toBeDefined() From a09fa2940dccccc9867ea268466f39e5ec2e5a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 14:33:13 +0100 Subject: [PATCH 2/4] fix(bindx-react): declare a params alias view when HasMany resolves it, and fall back when unfetched The resolver now declares the alias's view membership like the fields getter does, so an orderBy-only keeps showing items added to the relation. It returns nothing for an alias the parent's selection did not fetch, and then reads the field as given. The compiler also bails on a with spread props, which may carry params, and a has-many child merged with totalCount keeps it. --- .../src/selection/SelectionScope.ts | 4 ++ packages/bindx-compiler/src/jsx.ts | 7 ++- .../bindx-compiler/tests/analyzer.test.ts | 5 ++ .../src/jsx/components/HasMany.tsx | 13 ++-- packages/bindx/src/handles/EntityHandle.ts | 42 +++++++++---- .../bindx/src/handles/HasManyListHandle.ts | 7 ++- ...dHasManyFetchesUnfilteredRelation.test.tsx | 59 ++++++++++++++++++- 7 files changed, 114 insertions(+), 23 deletions(-) diff --git a/packages/bindx-client/src/selection/SelectionScope.ts b/packages/bindx-client/src/selection/SelectionScope.ts index ca430ff2..93b9424f 100644 --- a/packages/bindx-client/src/selection/SelectionScope.ts +++ b/packages/bindx-client/src/selection/SelectionScope.ts @@ -84,6 +84,10 @@ export class SelectionScope { childScope.addScalar('id') this.children.set(alias, childScope) this.relationMeta.set(alias, { fieldName, isArray: true, params }) + } else if (params.totalCount) { + // totalCount is not part of the alias: one reader asking for it is enough. + const meta = this.relationMeta.get(alias)! + meta.params = { ...meta.params, totalCount: true } } return childScope } diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index e9fcce1d..5c087f0e 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -390,9 +390,12 @@ export class JsxAnalyzer { } return // no entity reaches this element → nothing it collects belongs to this selection } - if (many && HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null)) { + const mayHaveParams = HASMANY_PARAM_KEYS.some(key => findAttr(node, key) !== null) + || node.openingElement.attributes.some(attr => t.isJSXSpreadAttribute(attr)) + if (many && mayHaveParams) { // The runtime collects a parameterised under its params alias, next to the - // plain relation, and renders that alias; a field map holds one node per field. + // plain relation, and renders that alias; a field map holds one node per field. A + // spread may carry params, so it counts as having them. throw new BailError({ code: 'HASMANY_PARAMS', message: ' with params' }) } const item = many ? consumeMany(res.ref) : consumeRelation(res.ref) diff --git a/packages/bindx-compiler/tests/analyzer.test.ts b/packages/bindx-compiler/tests/analyzer.test.ts index 7cd322c2..282d1a2f 100644 --- a/packages/bindx-compiler/tests/analyzer.test.ts +++ b/packages/bindx-compiler/tests/analyzer.test.ts @@ -77,6 +77,11 @@ describe('bail triggers (direct)', () => { expect(bail(`({ article }) => `)).toBe('COMPUTED_MEMBER') }) + test('HasMany with spread props, which may carry params', () => { + expect(bail(`({ article }) => {t => }`)) + .toBe('HASMANY_PARAMS') + }) + test('HasMany params inside a component', () => { expect(bail(`({ article }) => {t => }`)) .toBe('HASMANY_PARAMS') diff --git a/packages/bindx-react/src/jsx/components/HasMany.tsx b/packages/bindx-react/src/jsx/components/HasMany.tsx index ba6445d8..f1441b13 100644 --- a/packages/bindx-react/src/jsx/components/HasMany.tsx +++ b/packages/bindx-react/src/jsx/components/HasMany.tsx @@ -46,12 +46,15 @@ function HasManyImpl< TSchema extends Record = Record, >({ field, children, filter, orderBy, limit, offset }: HasManyProps): ReactElement { // With parameters the data was fetched under the params alias (see getSelection); the - // field the children received reads the plain relation, so switch to that alias. + // field the children received reads the plain relation, so switch to that alias. A + // selection that did not fetch the alias (a compiled one, a `useEntity` definer, params + // changed since collection) leaves the field as given. const fieldName = field[FIELD_REF_META].fieldName const alias = generateHasManyAlias(fieldName, { filter, orderBy, limit, offset }) const resolveAlias = (field as unknown as { [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver })[HAS_MANY_ALIAS_RESOLVER] - const listRef = alias !== fieldName && resolveAlias - ? resolveAlias(alias) as unknown as HasManyRef + const aliasList = alias !== fieldName && resolveAlias ? resolveAlias(alias) : undefined + const listRef = aliasList + ? aliasList as unknown as HasManyRef : field // useHasMany() subscribes to store and returns HasManyAccessor with .map()/.items/.length @@ -103,8 +106,8 @@ hasManyWithSelection.getSelection = ( result.childScope = scope result.syntheticChildren = props.children(item, 0) } else { - // A field without a detached collector (not a collector proxy): its map() still - // yields a collector item that knows the target entity. + // No params, or a field that is not a collector proxy: map() yields a collector + // item that knows the target entity and writes into the plain relation. fullField.map((item, index) => { const scopeRef = (item as unknown as Record)[SCOPE_REF] if (scopeRef instanceof SelectionScope) { diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 6cd20f23..9a5634f9 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -372,13 +372,42 @@ export class EntityHandle extends Enti effectiveAlias, nestedSelection, [...this.entityPath, fieldName], - siblingAlias => this.hasMany(fieldName, siblingAlias, this.selection?.fields.get(siblingAlias)?.nested), + siblingAlias => this.resolveHasManyAlias(fieldName, siblingAlias), ) this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle }) return handle } + /** + * The has-many `fieldName` under `alias`, if this entity's selection fetched it; `undefined` + * otherwise, so the caller keeps reading the relation it already has. + */ + private resolveHasManyAlias(fieldName: string, alias: string): HasManyAccessor | undefined { + const meta = this.selection?.fields.get(alias) + if (!meta || meta.fieldName !== fieldName) { + return undefined + } + this.declareAliasViewMembership(fieldName, meta) + return this.hasMany(fieldName, alias, meta.nested) + } + + /** + * The alias is a hash of the params, so the store cannot tell a filtered view from one that + * only orders; declare which it is where the params are known, so an orderBy-only view keeps + * showing items added to the relation. + */ + private declareAliasViewMembership(fieldName: string, meta: SelectionFieldMeta): void { + if (meta.alias !== fieldName) { + this.store.declareHasManyViewMembership( + this.entityType, + fieldName, + meta.alias, + canHasManyParamsExcludeMembers(meta.hasManyParams) ? 'partial' : 'total', + ) + } + } + /** * Gets a raw (unproxied) has-one relation handle from cache. * Creates the handle if not cached yet. @@ -513,15 +542,8 @@ export class EntityHandle extends Enti // Has-many relation - return HasManyListHandle. // Thread the selected alias so the handle reads data stored under the // auto-generated alias (e.g. `tags_`) for params-bearing relations. - // The alias is a hash of the params, so the store cannot tell a filtered - // view from one that only orders — say so here, where the params are. - if (fieldMeta && fieldMeta.alias !== schemaFieldName) { - this.store.declareHasManyViewMembership( - this.entityType, - schemaFieldName, - fieldMeta.alias, - canHasManyParamsExcludeMembers(fieldMeta.hasManyParams) ? 'partial' : 'total', - ) + if (fieldMeta) { + this.declareAliasViewMembership(schemaFieldName, fieldMeta) } return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index b1c87e3f..656cfca6 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -28,13 +28,14 @@ import { createAliasProxy } from './proxyFactory.js' /** * Symbol for a has-many handle's alias resolver: given the alias of the same relation * selected with other parameters (`generateHasManyAlias`), it returns the handle that - * reads the data fetched under that alias. `` uses it so the list it - * renders is the filtered one, not the plain relation. + * reads the data fetched under that alias, or `undefined` when the parent's selection did + * not fetch it. `` uses it so the list it renders is the filtered one, + * not the plain relation. */ export const HAS_MANY_ALIAS_RESOLVER = Symbol('HAS_MANY_ALIAS_RESOLVER') /** Resolves a sibling alias of the same has-many relation; see {@link HAS_MANY_ALIAS_RESOLVER}. */ -export type HasManyAliasResolver = (alias: string) => HasManyAccessor +export type HasManyAliasResolver = (alias: string) => HasManyAccessor | undefined /** * HasManyListHandle provides access to a has-many relation (list of entities). diff --git a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx index e0528136..e8b6dbee 100644 --- a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx +++ b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx @@ -1,10 +1,10 @@ // Regression test for https://github.com/contember/bindx/issues/158 import '../../../setup' import { describe, test, expect, afterEach } from 'bun:test' -import { render, waitFor, cleanup } from '@testing-library/react' +import { act, render, waitFor, cleanup } from '@testing-library/react' import React from 'react' -import { BindxProvider, MockAdapter, Entity, Field, HasMany } from '@contember/bindx-react' -import type { QueryFieldSpec } from '@contember/bindx' +import { BindxProvider, MockAdapter, Entity, Field, HasMany, createComponent } from '@contember/bindx-react' +import type { HasManyAccessor, QueryFieldSpec } from '@contember/bindx' import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../../shared' afterEach(() => { @@ -110,4 +110,57 @@ describe(' with parameters', () => { expect(getByTestId(container, 'react').textContent).toBe('React') expect(getByTestId(container, 'javascript').textContent).toBe('#f7df1e') }) + + test('should keep showing items added to the relation in an orderBy-only list', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let tags: HasManyAccessor<{ id: string; name: string; color: string }> | null = null + + const { container } = render( + + + {article => { + tags = article.tags as unknown as typeof tags + return ( +
+ {tag => } +
+ ) + }} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('ReactJavaScript') + }) + + act(() => { + tags!.add({ name: 'Zed', color: '#000' }) + }) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toContain('Zed') + }) + }) + + test('should render the filtered list from a component collected implicitly', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + const ReactTags = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + {tag => } + )) + + const { container } = render( + + + {article =>
} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'tags').textContent).toBe('React') + }) + }) }) From c41b91750532aaf40ef85f73779b0bfc17085302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 14:40:07 +0100 Subject: [PATCH 3/4] fix(bindx): resolve a HasMany params alias on an entity without a selection HasManyDataGrid rows are entity handles without a selection, so the resolver could not find the alias and the HasMany read the plain relation, which now carries only ids. The resolver takes the params, computes the alias itself and, without a selection, returns the alias handle with its view membership derived from the params. --- .../src/jsx/components/HasMany.tsx | 8 ++-- packages/bindx/src/handles/EntityHandle.ts | 36 ++++++++++----- .../bindx/src/handles/HasManyListHandle.ts | 13 +++--- ...dHasManyFetchesUnfilteredRelation.test.tsx | 44 ++++++++++++++++++- 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/packages/bindx-react/src/jsx/components/HasMany.tsx b/packages/bindx-react/src/jsx/components/HasMany.tsx index f1441b13..14c9b866 100644 --- a/packages/bindx-react/src/jsx/components/HasMany.tsx +++ b/packages/bindx-react/src/jsx/components/HasMany.tsx @@ -47,12 +47,10 @@ function HasManyImpl< >({ field, children, filter, orderBy, limit, offset }: HasManyProps): ReactElement { // With parameters the data was fetched under the params alias (see getSelection); the // field the children received reads the plain relation, so switch to that alias. A - // selection that did not fetch the alias (a compiled one, a `useEntity` definer, params - // changed since collection) leaves the field as given. - const fieldName = field[FIELD_REF_META].fieldName - const alias = generateHasManyAlias(fieldName, { filter, orderBy, limit, offset }) + // selection that did not fetch the alias (a `useEntity` definer, params changed since + // collection) leaves the field as given. const resolveAlias = (field as unknown as { [HAS_MANY_ALIAS_RESOLVER]?: HasManyAliasResolver })[HAS_MANY_ALIAS_RESOLVER] - const aliasList = alias !== fieldName && resolveAlias ? resolveAlias(alias) : undefined + const aliasList = resolveAlias?.({ filter, orderBy, limit, offset }) const listRef = aliasList ? aliasList as unknown as HasManyRef : field diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index 9a5634f9..ed5d724e 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -13,7 +13,8 @@ import { } from '../core/actions.js' import { FIELD_REF_META, type FieldRefMeta, type FieldAccessor, type HasOneAccessor, type HasManyAccessor, type EntityFieldsAccessor, type Unsubscribe, type EntityAccessor } from './types.js' import { deepEqual } from '../utils/deepEqual.js' -import { canHasManyParamsExcludeMembers } from '../utils/aliasGenerator.js' +import { canHasManyParamsExcludeMembers, generateHasManyAlias } from '../utils/aliasGenerator.js' +import type { HasManyParams } from '../selection/index.js' import { createClientError, type ErrorInput, type FieldError } from '../errors/types.js' import type { EventTypeMap, @@ -372,7 +373,7 @@ export class EntityHandle extends Enti effectiveAlias, nestedSelection, [...this.entityPath, fieldName], - siblingAlias => this.resolveHasManyAlias(fieldName, siblingAlias), + params => this.resolveHasManyAlias(fieldName, params), ) this.relationHandleCache.set(cacheKey, { raw: handle as unknown as RelationHandleRaw, proxy: handle }) @@ -380,15 +381,26 @@ export class EntityHandle extends Enti } /** - * The has-many `fieldName` under `alias`, if this entity's selection fetched it; `undefined` - * otherwise, so the caller keeps reading the relation it already has. + * The has-many `fieldName` read with `params`, under their alias. With a selection it is + * returned only if the selection fetched that alias, `undefined` otherwise, so the caller + * keeps reading the relation it already has. An entity without a selection (e.g. a + * `HasManyDataGrid` row) has no way to tell and gets the alias handle, which reads the + * plain relation's data when nothing was stored under the alias. */ - private resolveHasManyAlias(fieldName: string, alias: string): HasManyAccessor | undefined { - const meta = this.selection?.fields.get(alias) + private resolveHasManyAlias(fieldName: string, params: HasManyParams): HasManyAccessor | undefined { + const alias = generateHasManyAlias(fieldName, params) + if (alias === fieldName) { + return undefined + } + if (!this.selection) { + this.declareAliasViewMembership(fieldName, alias, params) + return this.hasMany(fieldName, alias) + } + const meta = this.selection.fields.get(alias) if (!meta || meta.fieldName !== fieldName) { return undefined } - this.declareAliasViewMembership(fieldName, meta) + this.declareAliasViewMembership(fieldName, alias, meta.hasManyParams) return this.hasMany(fieldName, alias, meta.nested) } @@ -397,13 +409,13 @@ export class EntityHandle extends Enti * only orders; declare which it is where the params are known, so an orderBy-only view keeps * showing items added to the relation. */ - private declareAliasViewMembership(fieldName: string, meta: SelectionFieldMeta): void { - if (meta.alias !== fieldName) { + private declareAliasViewMembership(fieldName: string, alias: string, params: HasManyParams | undefined): void { + if (alias !== fieldName) { this.store.declareHasManyViewMembership( this.entityType, fieldName, - meta.alias, - canHasManyParamsExcludeMembers(meta.hasManyParams) ? 'partial' : 'total', + alias, + canHasManyParamsExcludeMembers(params) ? 'partial' : 'total', ) } } @@ -543,7 +555,7 @@ export class EntityHandle extends Enti // Thread the selected alias so the handle reads data stored under the // auto-generated alias (e.g. `tags_`) for params-bearing relations. if (fieldMeta) { - this.declareAliasViewMembership(schemaFieldName, fieldMeta) + this.declareAliasViewMembership(schemaFieldName, fieldMeta.alias, fieldMeta.hasManyParams) } return this.hasMany(schemaFieldName, fieldMeta?.alias, nestedSelection) } diff --git a/packages/bindx/src/handles/HasManyListHandle.ts b/packages/bindx/src/handles/HasManyListHandle.ts index 656cfca6..9058f830 100644 --- a/packages/bindx/src/handles/HasManyListHandle.ts +++ b/packages/bindx/src/handles/HasManyListHandle.ts @@ -5,6 +5,7 @@ import type { SnapshotStore } from '../store/SnapshotStore.js' import type { HasManyRemovalType } from '../store/RelationStore.js' import type { SchemaRegistry } from '../schema/SchemaRegistry.js' import type { SelectionMeta } from '../selection/types.js' +import type { HasManyParams } from '../selection/index.js' import { connectToList, addToList, @@ -26,16 +27,16 @@ import type { import { createAliasProxy } from './proxyFactory.js' /** - * Symbol for a has-many handle's alias resolver: given the alias of the same relation - * selected with other parameters (`generateHasManyAlias`), it returns the handle that - * reads the data fetched under that alias, or `undefined` when the parent's selection did - * not fetch it. `` uses it so the list it renders is the filtered one, - * not the plain relation. + * Symbol for a has-many handle's alias resolver: given has-many parameters, it returns the + * handle of the same relation under their alias (`generateHasManyAlias`), which reads the + * data fetched with them, or `undefined` when the parameters make no alias or the parent's + * selection did not fetch it. `` uses it so the list it renders is the + * filtered one, not the plain relation. */ export const HAS_MANY_ALIAS_RESOLVER = Symbol('HAS_MANY_ALIAS_RESOLVER') /** Resolves a sibling alias of the same has-many relation; see {@link HAS_MANY_ALIAS_RESOLVER}. */ -export type HasManyAliasResolver = (alias: string) => HasManyAccessor | undefined +export type HasManyAliasResolver = (params: HasManyParams) => HasManyAccessor | undefined /** * HasManyListHandle provides access to a has-many relation (list of entities). diff --git a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx index e8b6dbee..3ef20bc6 100644 --- a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx +++ b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx @@ -3,8 +3,8 @@ import '../../../setup' import { describe, test, expect, afterEach } from 'bun:test' import { act, render, waitFor, cleanup } from '@testing-library/react' import React from 'react' -import { BindxProvider, MockAdapter, Entity, Field, HasMany, createComponent } from '@contember/bindx-react' -import type { HasManyAccessor, QueryFieldSpec } from '@contember/bindx' +import { BindxProvider, MockAdapter, Entity, Field, HasMany, createComponent, useBindxContext, useSchemaRegistry } from '@contember/bindx-react' +import { EntityHandle, type HasManyAccessor, type QueryFieldSpec, type SchemaRegistry } from '@contember/bindx' import { getByTestId, queryByTestId, createMockData, schema, testSchema } from '../../../shared' afterEach(() => { @@ -163,4 +163,44 @@ describe(' with parameters', () => { expect(getByTestId(container, 'tags').textContent).toBe('React') }) }) + + test('should render the filtered list for an entity handle without a selection', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + + // A row like HasManyDataGrid builds it: the same entity, created without a selection, + // over the store the above filled. + function SelectionlessRow({ id }: { id: string }): React.ReactElement { + const { store, dispatcher } = useBindxContext() + const schemaRegistry = useSchemaRegistry() + const row = EntityHandle.create<{ id: string; tags: { id: string; name: string }[] }>( + id, + 'Article', + store, + dispatcher, + schemaRegistry as SchemaRegistry>, + ) + return ( + {tag => } + ) + } + + const { container } = render( + + + {article => ( +
+ {tag => } +
+
+ )} +
+
, + ) + + await waitFor(() => { + expect(getByTestId(container, 'row').textContent).toBe('React') + }) + // The plain relation holds both tags (with only their ids fetched); the filtered list one. + expect(getByTestId(container, 'row').querySelectorAll('i').length).toBe(1) + }) }) From 1b030f763e95c3262494a5baf970dcd8d22f391f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20P=C3=A1rys?= Date: Tue, 3 Nov 2026 14:44:33 +0100 Subject: [PATCH 4/4] fix(bindx): keep the field as given when a selectionless entity has no data under the alias In create mode, and on any entity without a selection whose alias was never fetched, the alias view fell back to the plain relation's server data but was declared partial, so items added to the relation never showed in a filtered or limited list. Without a selection the resolver now returns the alias handle only when the entity's data holds the alias. --- packages/bindx/src/handles/EntityHandle.ts | 13 ++++--- ...dHasManyFetchesUnfilteredRelation.test.tsx | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/bindx/src/handles/EntityHandle.ts b/packages/bindx/src/handles/EntityHandle.ts index ed5d724e..30208496 100644 --- a/packages/bindx/src/handles/EntityHandle.ts +++ b/packages/bindx/src/handles/EntityHandle.ts @@ -381,11 +381,10 @@ export class EntityHandle extends Enti } /** - * The has-many `fieldName` read with `params`, under their alias. With a selection it is - * returned only if the selection fetched that alias, `undefined` otherwise, so the caller - * keeps reading the relation it already has. An entity without a selection (e.g. a - * `HasManyDataGrid` row) has no way to tell and gets the alias handle, which reads the - * plain relation's data when nothing was stored under the alias. + * The has-many `fieldName` read with `params`, under their alias, when that alias was + * fetched: per the selection when the entity has one, per its stored data when it has none + * (a `HasManyDataGrid` row). Otherwise `undefined`, so the caller keeps reading the relation + * it already has; in create mode, for one, items added to the relation then still show. */ private resolveHasManyAlias(fieldName: string, params: HasManyParams): HasManyAccessor | undefined { const alias = generateHasManyAlias(fieldName, params) @@ -393,6 +392,10 @@ export class EntityHandle extends Enti return undefined } if (!this.selection) { + const data = this.store.getEntitySnapshot(this.entityType, this.entityId)?.data + if (!data || !(alias in data)) { + return undefined + } this.declareAliasViewMembership(fieldName, alias, params) return this.hasMany(fieldName, alias) } diff --git a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx index 3ef20bc6..400c3653 100644 --- a/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx +++ b/tests/react/relations/hasMany/filteredHasManyFetchesUnfilteredRelation.test.tsx @@ -203,4 +203,42 @@ describe(' with parameters', () => { // The plain relation holds both tags (with only their ids fetched); the filtered list one. expect(getByTestId(container, 'row').querySelectorAll('i').length).toBe(1) }) + + test('should show items added in create mode in a filtered or limited list', async () => { + const adapter = new MockAdapter(createMockData(), { delay: 0 }) + let tags: HasManyAccessor<{ id: string; name: string; color: string }> | null = null + + const { container } = render( + + + {article => { + tags = article.tags as unknown as typeof tags + return ( +
+
+ {tag => } +
+
+ {tag => } +
+
+ ) + }} +
+
, + ) + + await waitFor(() => { + expect(queryByTestId(container, 'filtered')).not.toBeNull() + }) + + act(() => { + tags!.add({ name: 'React', color: '#000' }) + }) + + await waitFor(() => { + expect(getByTestId(container, 'filtered').textContent).toBe('React') + }) + expect(getByTestId(container, 'limited').textContent).toBe('React') + }) })