Repository navigation
feat(react): an action's params values are templates, evaluated where properties are - #10282
Conversation
An action's params values are templates, evaluated where properties are: the node-level params bag and properties.params (and the legacy props.params) have every string leaf template-evaluated at any depth, in the SchemaRenderer evaluation memo, through one per-key rule. The unevaluated-expression diagnostic walks the same leaves with the same walker, so an unresolvable template is still reported, by its path. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…ostic radius; docs and changeset
The record-edit-modes guide returns to teaching `"recordId": "${record.id}"`
inside `params`, with what happens when a template cannot be evaluated.
Changeset: @object-ui/react minor.
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
The one config-bag predicate (objectui#6761) stays the only spelling of the object/array test in packages/react/src; the params walk narrows it by prototype instead of re-spelling it. Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
|
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
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ly the input list (objectstack-ai#10289) (objectstack-ai#10460) Fixes objectstack-ai#10289 Clause-②: yes ## What this does This implements ruling A on the card (comment `5825589480`, maintainer 「批 objectstack-ai#224 同意」): an action's `params` is only the `ActionParam[]` inputs list. A node's static execution values ride `properties.params`. - `action:button` and `action:icon` read static values only from the node's `properties.params`. They forward those values to the runner as `ActionDef.params`, which is where `navigate_create` / `navigate_edit` read `objectName` / `recordId`. - A node-level `params` **object** is not read. It is ignored, and a development build logs one warning per action (`[action:button] action "X" carries an OBJECT under node-level params; it is ignored ...`). The warning names `properties.params`, and `bodyExtra` for an api payload. It is silent in production. The precedent is the dev-only, warn-once `console.warn` that `auto-trigger.ts` and core `actionKeys.ts` use. - A node-level `params` **array** still works as the input list and is forwarded as `actionParams`. `action:icon` used to forward any `params` raw; it now routes through the same `paramsPayload` as `action:button`. The runner already treated an array `params` as `actionParams`, so the input path behaves as before. - The two channels are independent. An `action:bar` member, which is mounted without `SchemaRenderer`, can carry inputs in `params` and values in `properties.params`, and both are forwarded. - The renderer reads `schema.properties.params` itself instead of the hoisted `schema.params`. `SchemaRenderer` copies `properties.params` onto the node, and `action:bar` members skip that hoist. An identity check tells a hoisted copy apart from a node-level object. The helper is `packages/components/src/renderers/action/static-params.ts`. Docs: `content/docs/guide/record-edit-modes.md` and the `@object-ui/app-shell` README now teach `properties.params` in both examples. They say that a node-level `params` object is ignored, and how to migrate. The README also had a stale claim that templates inside `params` are not evaluated, which objectui#10282 had already made false. That claim is replaced. ## Census (taken on the base `0348bc9f1`, before the change) Instrument: a one-off scanner run over every tracked file (CHANGELOGs excluded). It walks every JSON document for a `params` key holding a plain object, and it matches `params` followed by an object literal in ts/tsx/js/mjs/md/mdx/yaml text. Each text hit was then classified by hand. Nothing re-derives this reading; it is a historical measurement, not a live count. **Authors of a node-level `params` object on an `action:button` / `action:icon` node: 13.** | where | sites | disposition | |---|---|---| | `content/docs/guide/record-edit-modes.md` | 2 | migrated | | `packages/app-shell/README.md` | 2 | migrated | | app-shell `AppContent.tsx`, the `navigate_*` handler comment | 2 | migrated | | `action-bodyShape-forward.test.tsx` (`action:button`) | 3 | migrated | | `action-forward-precedence.test.tsx` (the shared fixture) | 1 | migrated | | `action-params-templates-7867.test.tsx` | 3 | 2 migrated; CASE A flipped to pin the object form as not read | - JSON metadata under `examples/`, `apps/` and `schema-catalog`: **0**. The JSON walk found no `params` object anywhere. - Published `skills/`: **0**. Its one action `params` is the `ActionParam[]` form, in `skills/objectui/rules/protocol.md`. No governed file is touched. - Stored metadata outside this repo (`sys_metadata` rows): **NOT MEASURED**, because this repo cannot read it. - `.changeset/7867-action-params-templates.md` (pending) shows one node-level example. It is another card's changeset and was not edited. The new changeset says that the form is no longer read. - The other text hits are the runner's own `ActionDef.params` (the values channel that handlers and hosts build in code), query params, nav-item params, i18n label params and similar. None of them is an authored action node. ## Readers, and a premise correction - The **authored-node readers** of the object form were the `paramsPayload` non-array branch in `action:button`, and `action:icon`'s raw `params` forward. Both are moved. - app-shell `recordFormNavigation.ts` (`action.params?.recordId` / `objectName`) and `useObjectActions.ts` read the **runner's** `ActionDef.params`. They do not read the authored node. Core declares that key as the values map (`params?: Record string-to-any`), and `action:button` now fills it from `properties.params`. They cannot read `properties.params` themselves, because the handler receives an `ActionDef`, which has no `properties`. So they keep their reads. Only their docs changed: the `NavigationActionDef` docblock and the `AppContent` comment now describe the channel and teach `properties.params`. The card's list of these two files as object-form readers was half right: the keys they read are the right ones, but on the runner layer. ## Boundary: surfaces left unchanged here These also forward a non-array `params` as values, but they are spec **action** entries, not SDUI nodes. They have no `properties` bag, and another ruling governs them: - `element:button`'s inline `action`; - `action:group` / `action:menu` items; - `page:header`'s `dispatchHeaderAction`. For a `type: 'api'` action, the objectstack#5777 ruling keeps the runner reading an object `params` as the payload for one version window, with a dev warning, removed at 18. Spec already refuses the object form at the authoring door, and the ADR-0087 conversion rewrites it to `bodyExtra`. Retiring these surfaces now would pre-empt that window, so they are listed rather than changed. ## Tests New pin file `packages/components/src/renderers/action/__tests__/action-params-properties-10289.test.tsx` (21 tests). It covers `action:button` and `action:icon`, each on both mounts (direct registry mount and through `SchemaRenderer`), and pins the following: - values come from `properties.params`; - a node-level object is not read as values, and the warning fires once and names `properties.params`; - `properties.params` wins over a node-level object; - the `ActionParam[]` path still arrives as `actionParams`; - both channels together; - the warning is once per action, not per click, and silent in production. Union at `c3aff649a` (final HEAD), all run from the repo root: - `packages/components/src/renderers/action/`, the bodyShape / bodyExtra / element:button 8071 / page-header-actions suites, app-shell `recordFormNavigation.test.ts`, and `scripts/__tests__/` for doc-expression-carriage and both action-forward-parity suites: **32 files, 548 tests passed**. - `@object-ui/components` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`, which includes the tests): green at `c3aff649a`. - `@object-ui/app-shell` `type-check`: green at `5f7cb6fc5`. Only comments and docs changed in app-shell after that. ## Ablation Each leg ran through `ablation-replace.mjs`. The anchor was proven to land (anchor 1 to 0, blob changed), and the restore was proven on disk (blob equal to HEAD, `git diff HEAD` empty). These tests import the renderers by relative path, so the mutated source is what runs. The direction was predicted before each run. 1. `action:button` values read reverted to the old `params: schema.params`: **10 red**. That is button direct-mount properties, both not-read pins, direct-mount wins, the production pin, the 3 bodyShape wire pins, 7867 CASE A, and precedence's carried values. The SchemaRenderer-mount "reads from properties.params" pin stays green, as predicted, because the hoist puts the same object on `schema.params`. 2. `action:icon` back to a raw `params` forward: **8 red**. That is the icon properties, not-read (both mounts), wins, input list (both mounts), both-channels, and the precedence carried-values pins. 3. The dev warning deleted: **5 red**. That is the four once-warning pins and the per-action memo pin. ## Gates run locally - Green: `check:action-forward-parity`, `check:handler-key-reads`, `check:changeset-claims`, `check:pending-changeset-literals`, `changeset:check`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `check:doc-example-readers`, `check:unreferenced-sources`, `check:spec-symbols`, `check:component-surface-parity`, `check:test-path-roots`, `check:action-ref-convention`, `check-changeset-presence`, `check-doc-links`, `check-doc-expression-carriage` (report-only). - ESLint on the 9 changed ts/tsx files: 0 errors. The warnings are the files' existing `no-explicit-any` / react-refresh warnings. - `check-governed-queue-guard --test` on the 12 changed paths: NOT GOVERNED. - NOT MEASURED: `check:readme-exports`, because it needs every package's `dist/*.d.ts` and the README edit adds no import. Also NOT MEASURED: `check:sdui-registration-pins`, because it needs a console build and no registration changed. Both are left to CI. - `packages/types` was not touched, so the Spec Main Shape Gate type-check does not apply. ## Acceptance notes - `SchemaRenderer` still evaluates a node-level `params` bag (carrier 1 of objectui#10282). No in-tree renderer reads that value any more, so the evaluation is dead work, and the unevaluated-expression diagnostic can still name a leaf inside it. `@object-ui/react` was left unchanged to keep this PR to the renderers the ruling names. A follow-up can drop carrier 1 there. - Under `SchemaRenderer`, the `properties` hoist copies `properties.params` over a node-level `params`. So one node cannot carry both an input list (node-level) and values (`properties.params`) on that path; the values win. Nothing in the repo authors both, and base behaved the same way. Directly mounted `action:bar` members get both. - The guide now says the diagnostic reports an unresolvable template under `properties.params.recordId`. The 7867 pin now asserts that exact path. - objectstack-ai#10282's changeset (pending) shows the node-level form; see the census. Attribution: this change was written in session `https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S`. --- _Generated by [Claude Code](https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #7867
Clause-②: yes
What this does
Ruling A on the card (comment
5749025615, maintainer 「其他同意」): an action'sparamsvalues are templates, evaluated wherepropertiesare. Every string leaf of a node'sparamsbag - the node-levelparamsandproperties.paramsalike, at any depth, inside nested objects and arrays - is now template-evaluated in theSchemaRendererevaluation memo, with the same evaluator and scope that already evaluatesproperties. A metadata-authorednavigate_editbutton on a record page can name the record it sits on with"recordId": "${record.id}".Measured, before and after
Through the real
SchemaRenderer-> realaction:button-> realActionRunner, on a record page bound byRecordContextProviderto{ id: 'rec_1' }, withnavigate_editregistered as a handler. Values read off theActionDefthe handler received.142fdfd8properties.label: "L-${record.id}"L-rec_1L-rec_1params.recordId: "${record.id}"${record.id}rec_1properties.params.recordId${record.id}rec_1rec_1${nope.id}params.recordIdThe base column is the new pin file run on the unmodified tree: 4 failed, 1 passed (the CONTROL).
Design
evaluateConfigValue(key, value)is defined once inside the memo that already evaluatesproperties: aparamsbag has every string leaf evaluated; every other value evaluates exactly as before (per-value, shallow, CEL predicate envelope preserved). No second evaluator.paramswas not seen by the memo at all on base (it evaluated onlycontent, the bindable text keys,properties,propsand the condition keys), so it is routed into the same memo through the same function, before the hoist.properties.paramsgoes through it inside thepropertiesloop, before the hoist, so the hoistedschema.paramsandschema.properties.paramsagree.props.paramsgoes through it in the legacy-alias loop, so the two bag loops do not disagree about a key (the objectui#5123 / objectui#9100 precedent). Each authored value is evaluated exactly once.packages/react/src/utils/paramsBag.ts, which the unevaluated-expression diagnostic also uses, so what is evaluated and what is reported as left unresolved are one radius by construction.params(theActionParam[]definition listaction:buttonroutes toactionParams, whose members carry deferred predicates such asvisible,titleFormatand optionvisibleWhen) is left as authored; keys are never evaluated; non-plain objects (Date,Map, class instances) and functions pass through by identity; a container already on the current path (a cycle in a host-built bag) is passed through unwalked. An envelope-shaped object inside the bag is walked, not collapsed to itssource. Copy on write: the authored bag is never mutated. The plain-object test is built onisConfigBag(the objectui#6761 one-spelling pin stays green).utils/unevaluatedExpression.ts) walksparamsleaves with the same walker and names a finding by its path (params.recordId,properties.params.target.id). Its channel enumeration now namesparamstoo - otherwise an author whoseparamstemplate threw would be toldparamsis not a channel at all, the objectui#7849 defect one channel later.content/docs/guide/record-edit-modes.mdreturns to teaching"recordId": "${record.id}", and says what happens when a template cannot be evaluated (raw text plus the dev diagnostic) and that a misspelled field on a bound record resolves to nothing (both measured).@object-ui/reactminor, behaviour change stated.Blast radius
paramsbags in this repository carrying a literal${: none. Measured once on142fdfd8with a one-off scanner over every tracked json/ts/tsx/js/jsx/mjs/md/mdx/yaml/yml file (CHANGELOGs excluded), matching quoted and unquotedparamskeys followed by an object or array and reading the balanced block; its only hit is a JavaScript template literal in app-shell code, not metadata. Controls: the doc's twoparamsbags matched, and the card's CASE A / CASE B spellings matched. Nothing re-derives this reading; it is a historical measurement, not a live count.params:action:buttonandaction:icon.action:group/action:menureadparamsoff their ownactionsitems, which do not pass through the memo.Boundary, measured and not changed here
action:barmounts its members directly through the component registry, not throughSchemaRenderer, so neitherpropertiesnorparamsof a member passes through the memo: a member withparams.recordId: "${record.id}"on the same record page reached the handler raw. The ruling's own statement ("evaluated wherepropertiesare") holds there, since a member'spropertiesare not evaluated either. Raised with the dispatching seat;action-bar.tsxis outside this card's surface.Tests
New pins:
packages/components/src/renderers/action/__tests__/action-params-templates-7867.test.tsx(5): CONTROL, CASE A, CASE B, two levels down, unresolvable still reports. Lives in@object-ui/componentsbecause it drives the realaction:buttonand runner.packages/react/src/__tests__/SchemaRenderer.paramsTemplates-7867.test.tsx(18): the three carriers, what the walk does not touch (array params, keys, non-plain objects, asource-keyed bag, cycles, no mutation, other nested keys stay shallow), the diagnostic's path and radius, copy on write.Union at
c5cbe18d(final HEAD):packages/react/whole,packages/components/src/renderers/action/, the components action / page-header-action suites,scripts/__tests__/check-doc-expression-carriage.test.ts,scripts/__tests__/check-component-surface-parity.test.ts,packages/layout/src/__tests__/guide-layout-app-shell-doc.test.ts: 122 files, 1666 tests passed.@object-ui/reacttype-checkgreen atc5cbe18d;@object-ui/componentstype-checkgreen (its reading of@object-ui/reactis the built public surface, which no commit after the build changed).scripts/__tests__/178 passed, 2 skipped (at02a2fb45).Ablation (at
02a2fb45;c5cbe18dchanges one docblock only), throughablation-replace.mjswith the anchor proven to land (anchor 1 -> 0, replacement 0 -> 1) and the restore proven on disk (blob equal to HEAD,git diff HEADempty). The tests import@object-ui/reactthrough the vitest source alias, so the mutated source is what they run.paramsagain (theparamsbranch ofevaluateConfigValuedisabled): 12 red - CASE A, CASE B and the two-levels-down pin, plus 9 react pins (carriers, keys, non-plain,sourcebag, cycle, no-mutation); CONTROL, the array pin, the other-keys-stay-shallow pin and the unresolvable pins stay green. Direction as predicted before the run.paramswalk disabled: 3 red - the e2e "unresolvable still reports" pin and the two react diagnostic pins; everything else green. As predicted.Gates run locally:
check:control-bytes,check:new-line-citations(0 new),check-changeset-presence,check:changeset-claims,changeset:check,check-doc-expression-carriage(report-only),check-doc-component-types,check-doc-example-ids,check-doc-fence-languages,check-doc-links,check:doc-example-readers,check:spec-symbols, targeted ESLint on the changed source and test files (0 errors), all green.NOT MEASURED locally:
check-doc-snippet-typesandcheck-doc-example-types(they need a 34-package scoped build; this diff adds no ts/tsx fence and no@example). Left to CI.Acceptance notes
check-doc-expression-carriage's hit text saysSchemaRendererevaluates "exactly four channels";paramsleaves are now a fifth. The census reads only top-level string values, so it neither counts nor misjudges a nestedparamstemplate. Report-only; not changed here.UIActionSchema.paramsis declaredActionParam[]-only while the runtime and the guide use an objectparamsbag; and apropertiesvalue that is an object with a stringsourcekey (for exampleproperties.bodyExtra) is collapsed to that string by the shallow loop.paramsdefinition path, which this PR leaves untouched; no file overlaps with any open PR.Attribution: this change was written in session
https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC.Generated by Claude Code