Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
90811dc
fix(browser): select popover options by text
matej21 Aug 20, 2026
3365361
test(bindx-react): pin has-one subscription regressions
matej21 Aug 20, 2026
5890a40
test: failing repro for nested create temp IDs leaking into the next …
MalaRuze Aug 11, 2026
2a1fc5e
fix(bindx-client): union nested relation selections across sibling cr…
matej21 Aug 19, 2026
26da72f
fix(bindx): pair nested creates by elimination, not greedy first-fit …
matej21 Aug 19, 2026
c83c48f
perf(bindx): memoize the dirty-entity scan per store write version (#65)
matej21 Aug 19, 2026
06ca1ef
refactor(bindx): guard the dirty-version key behind a write chokepoin…
matej21 Aug 19, 2026
7a49621
test: failing repro for unstable accessor identity across renders in …
MalaRuze Jul 13, 2026
011c4f4
fix(bindx-react): give useEntityList items a stable accessor identity…
matej21 Aug 19, 2026
8cb259b
fix(bindx-react): subscribe implicit entity props; notify on store cl…
matej21 Aug 19, 2026
4ff94f7
test: failing reproducer for entity:persisting / entity:persisted events
jonasnobile May 10, 2026
1c19f5f
fix(bindx): emit the entity persist lifecycle events (#28)
matej21 Aug 19, 2026
cb76909
ci: run every non-browser test suite in the unit gate
matej21 Aug 20, 2026
63dc057
fix(bindx-react): keep the hook count fixed and subscribe DSL conditions
matej21 Aug 20, 2026
71b8b5d
fix(bindx): evict has-many item handles for ids that left the list
matej21 Aug 20, 2026
ebe9498
test(bindx): pin the store's silent-write notification gaps
matej21 Aug 20, 2026
4470e97
chore: wire up eslint with the React hooks rules
matej21 Aug 20, 2026
5701814
fix(bindx-dataview): analyze the JSX a relation column renderer returns
matej21 Aug 20, 2026
b583dc0
feat(bindx): add selection-erased entity view types
matej21 Aug 20, 2026
c00c7fa
fix(bindx): announce the writes that made the store go stale
matej21 Aug 20, 2026
418aa9b
chore: remove stale TypeScript lint directives
matej21 Aug 20, 2026
53136c7
fix(bindx-react): keep JSX component hook order stable
matej21 Aug 20, 2026
329d775
ci: enforce React hooks lint
matej21 Aug 20, 2026
7447dff
docs: generalize test scenario comments
matej21 Aug 20, 2026
f979ac6
fix(bindx-dataview): keep optional filter hooks stable
matej21 Aug 20, 2026
51a6580
fix(bindx): propagate nested relation notifications
matej21 Aug 20, 2026
b0b65dc
test(browser): select from stable option lists
matej21 Aug 20, 2026
520bf1f
test(browser): choose a visible author option
matej21 Aug 20, 2026
7ff074a
test(browser): verify author selection before dirty state
matej21 Aug 20, 2026
44d3c61
fix(bindx-ui): expose stable select option identity
matej21 Aug 20, 2026
041b85e
fix(bindx): honor nested persist cancellation and events
matej21 Aug 20, 2026
45f2374
fix(bindx-react): subscribe implicit interface props
matej21 Aug 20, 2026
54bf2b0
fix(bindx): keep parent-side deletes of entities that have their own …
matej21 Aug 21, 2026
ae5898c
refactor: bump the global version once per notification; key the list…
matej21 Aug 21, 2026
40bebf4
fix(bindx): drop nested updates of vetoed entities too
matej21 Aug 21, 2026
3c699df
fix(bindx): subscription bookkeeping edge cases in the store
matej21 Aug 21, 2026
560924d
fix(bindx-client): select connected ids and detect the has-many updat…
matej21 Aug 21, 2026
8889fb2
fix(bindx): stamp the live id into snapshots written under a rekeyed …
matej21 Aug 21, 2026
90e2194
fix(bindx): keep relation ops dropped for vetoed items pending after …
matej21 Aug 21, 2026
e439c71
fix(bindx-react): key useEntityList item accessors by their persisted id
matej21 Aug 21, 2026
f628635
fix(bindx-dataview): stop merging a relation cell's rendered selectio…
matej21 Aug 21, 2026
87d005f
fix(bindx): connect, not re-create, a planned has-many addition whose…
matej21 Aug 21, 2026
6358dc1
fix(bindx): offer collector-materialized entities to entity:persisting
matej21 Aug 21, 2026
84db810
fix(bindx): enumerate entity accessors as id + selected fields
matej21 Aug 21, 2026
0eb302c
fix(bindx): reconcile persisted relation baselines exactly
matej21 Aug 21, 2026
22859d4
fix(bindx): keep undo blocked across concurrent persists
matej21 Aug 21, 2026
bca06f2
fix(bindx-react): stabilize multi-ref subscriptions
matej21 Aug 21, 2026
47aeac7
fix(bindx-dataview): bind relation cells to live rows
matej21 Aug 21, 2026
73ce285
fix(bindx): reconcile immutable persistence executions
matej21 Aug 21, 2026
6afe94d
test(bindx): enforce fail-safe nested reconciliation
matej21 Aug 21, 2026
47ed880
fix(bindx-dataview): preserve typed runtime rows
matej21 Aug 21, 2026
5f0744e
fix(bindx): unify identity across persisted rekeys
matej21 Aug 21, 2026
a8df7c4
fix(bindx): carry fullPath on live field refs
matej21 Aug 24, 2026
4702d28
refactor(bindx-dataview): extract field-ref helpers into a leaf module
matej21 Aug 24, 2026
970c417
fix(bindx-dataview): key sorting state by the dotted field path
matej21 Aug 24, 2026
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ on:
pull_request:

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint

typecheck:
name: Typecheck
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ bun run build
# Type check
bun run typecheck

# Run all tests
bun test
# Run all tests (everything except tests/browser, which needs a live playground)
bun run test

# Run a specific test file
bun test tests/useEntity.test.tsx
Expand Down
165 changes: 164 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

33 changes: 33 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import reactHooks from 'eslint-plugin-react-hooks'
import tsParser from '@typescript-eslint/parser'

// Deliberately narrow: only the React hooks rules. The repo has no lint culture
// yet, so a broad style ruleset would bury the signal. See PR that added this.
export default [
{
ignores: [
'**/dist/**',
'**/node_modules/**',
'**/generated/**',
'**/*.d.ts',
],
},
{
files: ['packages/*/src/**/*.{ts,tsx}', 'tests/**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
plugins: {
'react-hooks': reactHooks,
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
]
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
"build": "tsc --build",
"dev": "tsc --build --watch",
"typecheck": "tsc --build",
"test": "bun test tests/unit tests/react tests/cases tests/*.test.ts tests/*.test.tsx",
"lint": "eslint .",
"test": "bun test --path-ignore-patterns='**/tests/browser/**'",
"test:browser": "bun test --timeout 30000 tests/browser/",
"test:all": "bun test",
"playground": "cd packages/example && bun run dev",
"playground:contember": "cd packages/example && VITE_CONTEMBER_API_URL=http://localhost:1581 VITE_CONTEMBER_API_TOKEN=0000000000000000000000000000000000000000 bun run dev",
"contember:up": "docker compose up -d",
Expand All @@ -26,8 +26,11 @@
"@testing-library/react": "^16.3.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@typescript-eslint/parser": "^8.67.0",
"@vitejs/plugin-react": "^5.1.2",
"bun-types": "^1.3.5",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.3.0",
Expand Down
133 changes: 73 additions & 60 deletions packages/bindx-client/src/graphql/mutationFragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,92 +76,105 @@ export function buildMutationSelection(
export function buildNodeSelectionFromMutationData(
data: Record<string, unknown>,
): GraphQlSelectionSet {
const fields: GraphQlSelectionSet = [new GraphQlField(null, 'id')]

for (const [fieldName, value] of Object.entries(data)) {
if (value === null || value === undefined) continue

if (Array.isArray(value)) {
const nested = buildSelectionFromOps(value)
if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested))
} else if (typeof value === 'object') {
const nested = buildSelectionFromCreateOrUpdate(value as Record<string, unknown>)
if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested))
} else {
fields.push(new GraphQlField(null, fieldName))
}
}
return buildSelectionFromDataObjects([data])
}

return fields
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

/**
* Extracts the inner data from a create or update operation and recurses.
* Unwraps a create/update/connect operation to the data object whose shape the
* response must echo.
*
* A connect contributes `{ id }` only: the response needs the connected id so a
* create op that differs from its sibling solely by what it connects can still be
* content-matched against its row.
*/
function buildSelectionFromCreateOrUpdate(
op: Record<string, unknown>,
): GraphQlSelectionSet | undefined {
if ('create' in op && typeof op['create'] === 'object' && op['create'] !== null) {
return buildNodeSelectionFromMutationData(op['create'] as Record<string, unknown>)
}
if ('update' in op && typeof op['update'] === 'object' && op['update'] !== null) {
const update = op['update'] as Record<string, unknown>
const data = ('data' in update ? update['data'] : update) as Record<string, unknown>
return buildNodeSelectionFromMutationData(data)
function extractOperationData(op: unknown): Record<string, unknown> | undefined {
if (!isRecord(op)) return undefined

const create = op['create']
if (isRecord(create)) return create

const update = op['update']
if (isRecord(update)) {
// hasMany: `{ update: { by, data } }`; hasOne: `{ update: <data> }`. Both keys are
// required to tell them apart — a plain JSON column may be named `data`.
const by = update['by']
const data = update['data']
return isRecord(by) && isRecord(data) ? data : update
}

const connect = op['connect']
if (isRecord(connect)) return { id: connect['id'] }

return undefined
}

/**
* Merges selections from all create/update operations in a hasMany array.
* Collects the union of scalar + relation fields across all operations.
* Builds one selection set covering every given data object.
*
* Sibling ops in a hasMany often carry different subsets of the same relation
* (unset fields are absent from create data), so both scalars and nested
* relations are unioned — keeping only the last shape would emit a selection
* the other siblings' responses cannot be content-matched against.
*/
function buildSelectionFromOps(ops: unknown[]): GraphQlSelectionSet | undefined {
function buildSelectionFromDataObjects(
dataObjects: readonly Record<string, unknown>[],
): GraphQlSelectionSet {
const scalarFields = new Set<string>()
const nestedFields = new Map<string, Record<string, unknown>>()
let hasOps = false
const nestedOps = new Map<string, unknown[]>()

for (const item of ops) {
if (typeof item !== 'object' || item === null) continue
const op = item as Record<string, unknown>

const innerData =
('create' in op && typeof op['create'] === 'object' && op['create'] !== null)
? op['create'] as Record<string, unknown>
: ('update' in op && typeof op['update'] === 'object' && op['update'] !== null)
? (() => { const u = op['update'] as Record<string, unknown>; return ('data' in u ? u['data'] : u) as Record<string, unknown> })()
: null

if (!innerData) continue
hasOps = true
const collectNested = (fieldName: string, ops: readonly unknown[]): void => {
const collected = nestedOps.get(fieldName)
if (collected) {
collected.push(...ops)
} else {
nestedOps.set(fieldName, [...ops])
}
}

for (const [key, value] of Object.entries(innerData)) {
for (const data of dataObjects) {
for (const [fieldName, value] of Object.entries(data)) {
if (value === null || value === undefined) continue
if (typeof value === 'object') {
nestedFields.set(key, value as Record<string, unknown>)
} else {
scalarFields.add(key)

if (Array.isArray(value)) {
collectNested(fieldName, value)
} else if (isRecord(value)) {
collectNested(fieldName, [value])
} else if (fieldName !== 'id') {
scalarFields.add(fieldName)
}
}
}

if (!hasOps) return undefined

const fields: GraphQlSelectionSet = [new GraphQlField(null, 'id')]

for (const fieldName of scalarFields) {
fields.push(new GraphQlField(null, fieldName))
}

for (const [fieldName, value] of nestedFields) {
if (Array.isArray(value)) {
const nested = buildSelectionFromOps(value)
if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested))
} else {
const nested = buildSelectionFromCreateOrUpdate(value as Record<string, unknown>)
if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested))
}
for (const [fieldName, ops] of nestedOps) {
const nested = buildSelectionFromOps(ops)
if (nested) fields.push(new GraphQlField(null, fieldName, {}, nested))
}

return fields
}

/**
* Merges the selections of all create/update operations written to one field.
*/
function buildSelectionFromOps(ops: readonly unknown[]): GraphQlSelectionSet | undefined {
const dataObjects: Record<string, unknown>[] = []

for (const op of ops) {
const data = extractOperationData(op)
if (data) dataObjects.push(data)
}

if (dataObjects.length === 0) return undefined

return buildSelectionFromDataObjects(dataObjects)
}
1 change: 0 additions & 1 deletion packages/bindx-dataview/src/HasManyDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ import { useDataGridSetup } from './useDataGridSetup.js'

export interface HasManyDataGridProps<TEntity extends object = object> {
/** Has-many relation field from parent entity */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
field: HasManyRef<TEntity, any>
/** Children render function: receives entity proxy `it`, returns column markers + layout */
children: (it: EntityAccessor<TEntity>) => ReactNode
Expand Down
4 changes: 0 additions & 4 deletions packages/bindx-dataview/src/columnLeaf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,8 @@ export interface ChildrenAnalysisResult {
*/
export function analyzeChildren(
elements: React.ReactNode,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
markerTypes: ReadonlySet<React.ComponentType<any>>,
): ChildrenAnalysisResult {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const collected = new Map<React.ComponentType<any>, unknown[]>()
for (const type of markerTypes) {
collected.set(type, [])
Expand All @@ -121,9 +119,7 @@ export function analyzeChildren(

function walkTree(
elements: React.ReactNode,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
markerTypes: ReadonlySet<React.ComponentType<any>>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
collected: Map<React.ComponentType<any>, unknown[]>,
): void {
React.Children.forEach(elements, (child) => {
Expand Down
8 changes: 8 additions & 0 deletions packages/bindx-dataview/src/columnTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export function accessField(accessor: EntityAccessor<object>, fieldName: string)
return current
}

/**
* Access a related entity accessor from a parent row accessor by field name.
* EntityAccessor is a Proxy — bracket notation triggers the get trap.
*/
export function getRelatedAccessor(item: EntityAccessor<object>, fieldName: string): EntityAccessor<object> | null {
return accessField(item, fieldName) as EntityAccessor<object> | null
}

function extractScalarValue<T>(accessor: EntityAccessor<object>, fieldName: string): T | null {
const fieldRef = accessField(accessor, fieldName) as { value?: unknown } | null
if (!fieldRef || typeof fieldRef !== 'object') return null
Expand Down
56 changes: 1 addition & 55 deletions packages/bindx-dataview/src/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
import React, { ReactNode } from 'react'
import type { FieldRef, HasOneRef, HasManyRef, FilterHandler, FilterArtifact, EntityAccessor, EnumFilterArtifact, EnumListFilterArtifact, SelectionMeta } from '@contember/bindx'
import { SelectionScope } from '@contember/bindx'
import { FIELD_REF_META, createCollectorProxy } from '@contember/bindx-react'
import { createColumn, createColumnStaticRender, type ColumnRenderProps } from './createColumn.js'
import { extractFieldName } from './fieldRef.js'
import { accessField } from './columnTypes.js'
import { createRelationColumn, hasOneCellConfig, hasManyCellConfig, type RelationColumnProps } from './createRelationColumn.jsx'
import {
Expand All @@ -37,60 +37,6 @@ import { ColumnLeaf, type ColumnLeafProps } from './columnLeaf.js'

export type ColumnMeta = ColumnLeafProps

// ============================================================================
// Extraction Helpers
// ============================================================================

interface FieldRefMetaCarrier {
readonly [FIELD_REF_META]: {
readonly entityType: string
readonly fieldName: string
readonly fullPath?: readonly string[]
readonly isArray: boolean
readonly isRelation: boolean
readonly enumName?: string
}
}

/** Type guard: checks if a value carries FIELD_REF_META symbol. */
export function hasFieldRefMeta(ref: unknown): ref is FieldRefMetaCarrier {
return ref != null && typeof ref === 'object' && FIELD_REF_META in ref
}

/**
* Extract the dotted field path from a field ref (works in both collector and
* runtime proxies). For fields reached through has-one relations
* (e.g. `it.author.name`) this is the full dotted path (`"author.name"`) so the
* DataGrid can build correct nested where/orderBy clauses; for top-level fields
* it is simply the field name (`"title"`).
*/
export function extractFieldName(ref: unknown): string | null {
if (!hasFieldRefMeta(ref)) return null
const meta = ref[FIELD_REF_META]
const fullPath = meta.fullPath
return fullPath && fullPath.length > 0 ? fullPath.join('.') : meta.fieldName
}

/** Extract enum name from a field ref (if field is an enum). */
export function extractEnumName(ref: unknown): string | undefined {
return hasFieldRefMeta(ref) ? ref[FIELD_REF_META].enumName : undefined
}

/** Extract related entity type name from a relation field ref. */
export function extractRelatedEntityName(ref: unknown): string | null {
if (!hasFieldRefMeta(ref)) return null
const meta = ref[FIELD_REF_META]
return meta.entityType || null
}

/**
* Access a related entity accessor from a parent row accessor by field name.
* EntityAccessor is a Proxy — bracket notation triggers the get trap.
*/
export function getRelatedAccessor(item: EntityAccessor<object>, fieldName: string): EntityAccessor<object> | null {
return accessField(item, fieldName) as EntityAccessor<object> | null
}

// ============================================================================
// Default Cell Renderers
// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion packages/bindx-dataview/src/createColumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import React from 'react'
import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor } from '@contember/bindx'
import type { ColumnTypeDef } from './columnTypes.js'
import { ColumnLeaf, type ColumnLeafProps } from './columnLeaf.js'
import { extractFieldName, extractEnumName } from './columns.js'
import { extractFieldName, extractEnumName } from './fieldRef.js'

// ============================================================================
// Render Props
Expand Down
Loading