Repository navigation
feat(core,react): {record_id} in filter values resolves to the mounted record, and is refused by name without one (objectui#7297) - #11205
Conversation
…d record, refused by name without one
@objectstack/spec 17.5.0 declares RECORD_CONTEXT_TOKENS = {record_id}, the id of
the record a type: 'record' page shows, resolved only by the page renderer.
- core: resolveContextTokens fills {record_id} from FilterTokenScope.recordId
(new optional member). With none in scope it is refused by name through
onUnresolved, in the voice of an unresolved session token, and left as
written: never null, never dropped, never reported as an unknown spelling.
- react: useFilterScope() adds recordId from the nearest RecordContextProvider
(the provider visibleWhen binds as record) and from nothing else; outside one
it hands back the session scope unchanged. FilterScopeProvider stays
session-only.
- react / plugin-grid / plugin-view: the three filter holds key on recordId
like every other scope member, so moving to the next record re-resolves
without a remount.
Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ
Co-authored-by: Claude <noreply@anthropic.com>
… sentence; changeset - content/docs/guide/data-source.md: a subsection on scoping a filter to the record in view, with an element:number dataSource example, the refusal outside a record context, and that it scopes what is shown, not access. - packages/react/README.md: useFilterScope / useResolvedFilter name the token, where its id comes from, and that the hold follows the record. - .changeset/7297-record-id-filter-token.md: core + react minor, plugin-grid + plugin-view patch. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
…7 pin object-ui/no-unused-imports reported it; the JSX runtime needs no binding. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude <noreply@anthropic.com>
|
changeset-claim-re-read
|
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Generated by Claude Code |
Contract reviewServed-tier: Inputs, and nothing else: card #7297 (body and all 7 comments, the rulings ① Derived judgments
② Semver level
③ Boundary flagsDev report:
Check-runs on the head, read 2026-09-30T11:21:29Z: 42 runs. 31 Implemented-by: VERDICT: PASS |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Delta record. This head is the GitHub update-branch merge of ① Derived judgmentsCarried over from record
So every judgment in ① of record ② Semver levelCarried over from record ③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #7297
Clause-②: yes
What this does
On a
type: 'record'page,{record_id}in a filter value now resolves to the id of the record the page shows. With no record in context it is refused by name and left as written.@objectstack/spec17.5.0 (already installed onmain) declares the token asRECORD_CONTEXT_TOKENS(objectstack-ai/objectstack#20003).@object-ui/core,resolveContextTokens:FilterTokenScopegains an optionalrecordId.{record_id}/${record_id}resolves from it and from nothing else. When it is missing, null or empty, the token is refused throughonUnresolved, the channel an unresolved{current_user_id}uses (aconsole.warnby default). The warning names the token and says there is no record in context. The value stays as written: nevernull, never dropped, and never reported as an unknown spelling or given a suggestion. A second record-context token in the spec breaks the compile through the samesatisfiescheck the session tokens have.@object-ui/react,useFilterScope(): this is the one seam. It returns the session scope plusrecordIdtaken from the nearestRecordContextProvider, the provider whose rowSchemaRendererbinds asrecordforvisibleWhen. It reads no URL param and no page variable.FilterScopeProviderstill carries session values only. Outside a record context the hook returns the provider's session object unchanged. Every data node that already resolves its filter through this hook now gets{record_id}too, with no per-surface code. That includeselement:number, where both itsproperties.filterand its component-leveldataSource.filtergo throughuseResolvedFilter(scopedFilter.filter, useFilterScope()).useResolvedFilter(@object-ui/react),useResolvedGridFilters(@object-ui/plugin-grid) anduseResolvedFilterSegments(@object-ui/plugin-view) now includerecordIdin their key alongside the other scope members. Moving from one record to the next therefore resolves again and re-queries without a remount (AGENTS.md feat: add live playground for interactive schema demonstration #8: nokey=bump).content/docs/guide/data-source.mdgets a new subsection with anelement:number+dataSource.filterexample, the refusal outside a record context, and the scope sentence (it scopes what is shown and is not access control). Thepackages/react/README.mdsection onuseFilterScope/useResolvedFilteris updated to match..changeset/7297-record-id-filter-token.md: core and reactminor, plugin-grid and plugin-viewpatch.Premises, measured on
81778b955before building{record_id}a member ofCONTEXT_TOKENS. It has not. The installed spec keepsCONTEXT_TOKENSas['current_user_id', 'current_org_id']and adds a separateRECORD_CONTEXT_TOKENS = ['record_id']withisRecordContextToken.classifyFilterToken('{record_id}')returns kindrecord-context.CONTEXT_TOKEN_SUGGESTIONSgained fourrecord_idnear-misses. At the base,record_idhad 0 non-test hits inpackages/core/src, against 9 hits forcurrent_user_idinfilter-tokens.tsas the control. So the client resolver passed{record_id}through with no warning at all: it was neither a context token nor a near-miss key.RecordContextProvider/useRecordContext()in@object-ui/react.RecordDetailViewand Studio'sPagePreviewfortype: 'record'drafts mount it.SchemaRendererreads the same provider to bindrecordforvisibleWhen.ElementNumberRendererno longer passesprops.filterstraight to the adapter. Since objectui#10666 it resolves the scoped filter (its own, AND-combined with the binding's) throughuseResolvedFilter(…, useFilterScope())before bothaggregateandfind. Soelements.tsxneeded no edit: the record id enters at the layer where the session tokens already resolve.UnresolvedFilterTokenError(FILTER_TOKEN_UNRESOLVED, 400) naming it, andelement:numbershows that error text with the empty dash instead of a count.{record_id}outside a record context now follows the same path. The server already refuses it on every path (resolveFilterTokensin objectstackpackages/core).Where the change landed, compared with the claim's file surface
The claim named
filter-tokens.ts, the record-context seam, their tests, the doc line and the changeset. The seam turned out to bepackages/react/src/hooks/useFilterScope.ts, which is the only producer ofrecordId.useResolvedFilter.ts,ObjectGrid.tsxand plugin-view'sObjectView.tsxare the three holds that had to addrecordIdto their key so the value follows the record.elements.tsxis unchanged.Tests
New pins (28 tests in 5 files):
packages/core/src/utils/__tests__/filter-tokens.recordContext-7297.test.ts: resolution in every filter shape and both spellings; two records give two values; resolution sits alongside the session tokens with neither reading the other; refusal with no member, null, undefined or emptyrecordId, where the value stays as written, one warning names"{record_id}"and "no record in context", and the warning is never "not a recognised token" or "did you mean"; the defaultconsole.warn;resolveFilterPlaceholders.packages/react/src/hooks/__tests__/useFilterScope.recordContext-7297.test.tsx: the scope with and without a record provider, a numeric key narrowed to a string, and a provider with no record.useResolvedFilterfollows the record change without a remount.packages/components/src/renderers/basic/__tests__/elementNumber.recordIdFilterToken-7297.test.tsx: through the realSchemaRenderer, the count follows the record (3, then 5, with no remount).dataSource.filtertakes the token.{current_user_id}still resolves to the viewer on a record page. Outside a record context the aggregate receives{record_id}as written and the warning names it. A server-shaped refusal (code: 'FILTER_TOKEN_UNRESOLVED',status: 400) renders its message and the dash, the same as the{current_user_id}control.packages/plugin-grid/src/__tests__/ObjectGrid.recordIdFilterToken-7297.test.tsxandpackages/plugin-view/src/__tests__/ObjectView.recordIdFilterToken-7297.test.tsx: each re-queries with the next record without a remount. The grid also covers the refusal outside a record context.Verification (final head
0fb29c5fa; every command run from the worktree root, exit codes captured before any pipe)pnpm exec vitest runon the 5 pin files givesTest Files 5 passed (5),Tests 28 passed (28), andos-verify-lock: VERDICT command-exit 0.pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-view...' --filter '@object-ui/components...' buildgives VERDICT command-exit 0.pnpm --filterover core / react / plugin-grid / plugin-view / componentstype-check(tsc --noEmit && tsc -p tsconfig.test.json) reportstype-check: Donefor all five, VERDICT command-exit 0.tsc -p tsconfig.test.json --listFilesOnlyconfirms each package's test config includes its new pin file.vitest run packages/core/ packages/react/: 296 files, 5130 passed, 27 skipped.vitest run packages/plugin-view/: 63 files, 599 passed.packages/plugin-grid/, run in two halves by file list: 84 + 88 = 172 files, 731 + 853 tests passed. That is all 172*.test.ts(x)files in the package;git ls-filesfinds no other test-file spelling there.RecordContextProvider, namesuseRecordContext, or readsuseFilterScope/FilterScopeProvider, plus theelement:number/record-picker/data-listtests. By package: app-shell 9, apps/console 2, components 29, plugin-dashboard 1, plugin-detail 75, plugin-form 1, plugin-list 2.useFilterScope()returns the provider's session object unchanged unless aRecordContextProviderwith a non-nullrecordIdis mounted above it. So the change is only observable under that provider.eslint --format jsonon the 11 changed.ts/.tsxfiles: 11 files, 0 errors. The six source files have the same error/warning counts at base (git show 81778b955:PATHvia--stdin) as at head.eslint.config.jsextendstseslint.configs.recommended, not a type-checked config, and sets noparserOptions.project, so the diff cannot change the verdict on any untouched file. The repo-widepnpm lintis CI's.node scripts/check-changeset-presence.mjsexit 0 ("11 source file(s) of 5 released package(s) changed, and this change declares 1 changeset(s)").pnpm check:control-bytesexit 0.pnpm check:new-line-citationsexit 0 (0 new).pnpm check:doc-typesexit 0.check-changeset-no-major/check-changeset-fixedexit 0.check:changeset-claimsexit 0 (report-only).check:pending-changeset-literalsexit 0.pnpm check:doc-snippets. It exits 2 (PRECONDITION NOT MET) because the 34-package doc closure is not built here. The docs diff adds nots/tsxfence (onejsonblock plus prose), so the gate has nothing new of this change's to compile. CI'sdoc-snippet-types.ymlbuilds the closure and runs it.Ablations (one-time proof; each on the committed fix, through
ablation-replace.mjs, anchor 1 to 0 and the blob hash changed, then restored to the HEAD blob withgit diff HEADempty)coreresolver branch disabled (if (isRecordContextToken(token))changed toif (false && …)): 20 of 28 pin tests fail across all 5 files. The 8 that stay green are the spec-tuple ratchet, the "a record id does not resolve{current_user_id}" case, the fiveuseFilterScopecomposition cases, and the server-refusal rendering control.useFilterScopestops addingrecordId: 9 fail (react, components, grid, view). The core file stays green.recordIdterm removed from each hold:useResolvedFilter: 2 fail (the react follow-the-record case andelement:numbertwo records, two counts).ObjectGrid: 1 fail.ObjectView: 1 fail.Acceptance notes (observations, not filed)
FilterTokenScopemember has to be added to all three; the ablations above show that a hold missing one goes stale silently. Carrier: none.packages/typesJSDoc lines that list the context tokens ({current_user_id},{current_org_id}, the date macros) do not mention{record_id}. They are descriptive, so they were left alone. Carrier: none.{record.id}/{record-id}near-misses: the client's whole-token pattern is[a-zA-Z0-9_]+, so these pass the client quietly. The spec's lint and the server refuse them. This was already recorded infilter-tokens.spec-derived-7265.test.tsat 17.5.0 and is not new here.The session for this change is
https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ(dispatched os-dev, seatdomain:ui#1, claim comment 5908754368).Generated by Claude Code