Repository navigation
fix(react): a config-bag data object carrying a source field reaches the renderer whole - #10347
Conversation
…o the renderer whole
SchemaRenderer's per-value `properties` / `props` loops passed every
non-predicate value to `ExpressionEvaluator.evaluate`, whose first step
unwraps ANY object with a string `source` to that bare string. An authored
data object such as `bodyExtra: { source: 'web', campaign: 'spring' }`
therefore reached the runner as "web" (objectui#10288).
On a non-predicate key, an object now reaches `evaluate` only when it is a
spec Expression envelope (it carries a string `dialect`, which
`@objectstack/spec`'s ExpressionSchema requires). Every other object is
handed over as authored. Predicate-chain keys are unchanged: the CEL
envelope is still preserved (objectui#9100 / #9107) and the dialect-less
`{ source }` ExpressionWire form is still evaluated there.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
… across the census
The end-to-end probe drives `properties.bodyExtra: { source, campaign }`
through the real SchemaRenderer, action:button and ActionRunner, with the
node-level spelling as its control and the objectui#9100 / #9107 CEL
predicate rows on the same mount path. The fix-site pin is parametrised
over the census of object-valued inputs on this path, on both channels,
plus controls for what the loops still evaluate.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…ct fix Declares the `@object-ui/react` patch with the behaviour change stated plainly, and points the guard's docblock at the two pins that measure it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…ed module variable `react-hooks/globals` refuses a component that reassigns a variable declared outside it; the probe now reports what it was handed to a `vi.fn` and the assertions read its last call. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
…operties-source-collapse
|
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
|
…operties-source-collapse
The change alters what one authored shape evaluates to (a dialect-less
`{ source }` on a config-bag key outside the predicate chain and `params`),
so under objectui's version policy it is a minor with the semantics in the
body. The closing paragraph now states that change and how to keep such a
value evaluated.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC
✅ 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
|
Fixes #10288
Clause-②: yes
What was wrong
SchemaRenderer's two per-value config-bag loops (properties, and its legacy aliasprops) handed every non-predicate value toExpressionEvaluator.evaluate, whose first step unwraps ANY object with a stringsourceto that bare string. So an authored data object that merely has a field calledsourcewas silently replaced by that one string:action:buttonwithproperties.bodyExtra: { source: 'web', campaign: 'spring' }reached the runner asbodyExtra: "web". The same object at node level, which no loop visits, arrived intact.Mechanism, printed at
origin/main7616d893(PM zone-2 assumption 1: holds)ExpressionEvaluator.evaluate, first branch:if (expression && typeof expression === 'object' && typeof (expression as any).source === 'string') { expression = (expression as any).source as string; }SchemaRenderer.tsx:preservePredicateEnvelopeisPREDICATE_CHAIN_KEYS.has(key) && isCelEnvelope(value) ? value : evaluate(value), reached throughevaluateConfigValuefrom both thepropertiesloop and thepropsloop.paramsis the one key walked leaf by leaf (paramsBag.ts, objectui#7867).Route: (a), narrowing what counts as an envelope, sited at the loop
Four readings, taken before choosing:
@objectstack/spec17.4.0 (dist/expression.zod-*.d.ts):ExpressionSchemadeclaresdialectas a required enum (cel/cron/template) andsourceoptional. Every spec envelope carriesdialect.{ source }on a non-predicate key on purpose. Searchedexamples/**,apps/**,content/docs/**,skills/**and every test fixture underpackages/**for asourcekey whose value is a${…}template, and for object literals opening withsource:and carrying nodialect. Result: none on a non-predicate key. The dialect-less hits are either a predicate key (visible: { source: '${…}' }, whichExpressionWiredeclares) or DATA that happens to have asourcefield (flow edges, identity-import column mappings, reference-source unions,interfaceConfig: { source }, a drill-down filter on asourcefield). Control for the search: the same greps return the known predicate-key fixtures (SchemaRenderer.predicateEnvelopeDeclared.test.tsx,base-schema-predicate-envelope-7530.test.ts).{ dialect?, source }form is documented only on predicate keys:visible/hidden/disabled(content/docs/api/schema-reference.md), actioncondition, and the form*When/visibleOnkeys. No page teaches an envelope as the value of a non-predicate config key.@object-ui/core'sisRuntimeDefault(validation/server-owned-value.ts) already applies exactly this shape test, "Same shape test the engine applies before handing a default toExpressionEngine":{ dialect: string, source: string }is an instruction,{ source }withoutdialectis a literal (pinned inFormPage.test.ts, "seeds an object default that is NOT an Expression envelope").The readings agree, and no documented authoring form changes meaning, so this is route (a) and not a
needs_decision.Why not route (b), walking every object value leaf by leaf. It would deepen evaluation for every object value on both channels. Keeping those loops shallow is the objectui#4799 decision, and objectui#7867 took the deep walk for
paramsonly. It would also stop a spec{ dialect: 'template', source }envelope on a non-predicate key from resolving to its value: it would come back as an envelope with an interpolatedsource. The new control row "a spectemplateenvelope on a non-predicate key still collapses to its interpolated value" pins today's reading. So (b) is a contract change, not a narrower fix.Why at the loop and not inside
ExpressionEvaluator.evaluate.evaluate's own signature declares{ dialect?: string; source?: string }. The loop is also where objectui#9100 / #9107 sited the predicate protection. And a census ofevaluate's direct callers (git grepoverpackages/*/src, tests excluded) finds no other caller that hands it an arbitrary data object:ActionRunnerpassesconfirmText/redirect/target/ a script string,useExpressionhas no caller, andevaluateConditionhas its own separate unwrap. Narrowing inside core would also reach the predicate keys in the bag, where the dialect-less form IS declared. So@object-ui/coreis unchanged, and the radius is the two loops.The change (
packages/react/src/SchemaRenderer.tsxonly)isExpressionEnvelope(value): a config-bag object with a stringdialectAND a stringsource.isDataObjectValue(key, value): not a predicate-chain key, a config-bag object, and not an expression envelope.evaluateConfigValuehands such a value over as authored. Otherwise it behaves exactly as before, for both loops: theparamswalk, thenpreservePredicateEnvelope, thenevaluate.The one value whose meaning changes is an object on a config-bag key outside the predicate chain and
params, with a stringsourceand no stringdialect: it used to collapse to its evaluatedsource, and it now arrives whole. The following are unchanged:{ dialect: 'template' }and other dialect-carrying envelopes;{ source }is still evaluated);params.Diagnostic and walker (zone-2 item 3): no change needed, one radius kept. Evaluation of non-
paramsobjects stays per-value and shallow, which is the radiusunevaluatedExpression.tsscans: it reads string values only, plus theparamsleaves through the oneparamsBag.tswalker.paramsBag.tsalready walks an envelope as a plain object and never collapses one, which agrees with this route.Census: object-valued inputs on this path
Instrument.
ComponentRegistry.getAllConfigs()with the whole registration graph loaded (@object-ui/componentsplusapps/console'sregister-plugins, the pairpublic-contract.test.tsloads), keeping every input whosetypecarries anobjectarm. Run as a scratch vitest file at7616d893, not committed. Reading: 441 configs, 1681 inputs, 122 object-arm rows. The rows collapse to 80 distinct type + key pairs, because plugin types register twice (namespaced and bare), and to 42 distinct keys.record:alert.visibleis a predicate key and is left on the predicate path.action:bar:actions,systemActionsaction:group:actionsaction:menu:actionselement:button:action,label(string|object)element:record_picker:dataSource,emptyText(string|object),label(string|object),placeholder(string|object)element:text:content(string|object)element:text_input:description(string|object),label(string|object),placeholder(string|object)page:card:title(string|object)page:header:subtitle(string|object),title(string|object)plugin-dashboard:dashboard:dateRange,description(string|object),header,label(string|object)plugin-dashboard:object-metric:aggregate,compareTo,dataSource,drillDown,trendplugin-dashboard:object-pivot:dataSourceplugin-detail:detail-view:data,dataSource,footer,headerplugin-form:embeddable-form:dataSourceplugin-form:form-analytics:metricsplugin-form:object-form:dataSource,initialData,initialValues,mobile,submitBehaviorplugin-form:object-master-detail-form:dataSource,initialData,initialValuesplugin-grid:object-gridandview:grid:data,dataSource,exportOptions,grouping,navigation,operations,pagination,rowColor,selectionplugin-list:list-viewandview:list:dataSource,optionsplugin-view:object-view:listViews,navigationrecord:alert:action,body(string|object),title(string|object)record:chatter,record:discussion:feedrecord:line_items:dataSourcerecord:related_list:add,dataSourceui:filter-builder:valueui:form:defaultValuesui:header-bar:rightContent,searchview:filter-ui:valuesview:form:dataSourceThe instrument's blind spot, and its second half. A key a renderer reads off the node without declaring it as an input never reaches
getAllConfigs(). The card's ownbodyExtrais one such key:action:buttondeclares no object-arm input at all. From theaction:button/action:iconforward lists, the undeclared object-valued keys arebodyExtra,bodyShape,patch,resultDialog,onSuccessandtoast.paramsis already walked. Undeclared reads in other renderers are not enumerable this way, and the fix does not need them to be: the loop reads a key's name only to recogniseparamsand the predicate chain.Where a
sourcefield is ordinary, not exotic. These values are keyed by FIELD NAME, so a record with asourcecolumn (lead source) collapses:ui:form.defaultValues,object-form/object-master-detail-forminitialValues/initialData,detail-view.data,view:filter-ui.values, and the actionpatch.Pins
packages/components/src/renderers/action/__tests__/action-config-bag-source-key-10288.test.tsx. It drives the realSchemaRenderer, the realaction:buttonand the realActionRunner, then reads theActionDefa registered handler received.properties.bodyExtraarrives whole. It is red on the old collapse and green with the fix.bodyExtrais green both ways.properties.visibleandproperties.disabledcarrying a CEL envelope with ahas(…)discriminator, are green both ways.packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx:schema.propertiesand hoisted onto the node, or inschema.props.{ source: '${data.status}' }on a non-predicate key arrives as data.templateenvelope on a non-predicate key still resolves; a dialect-less{ source }onvisibleis still an expression (ExpressionWire, objectui#7530); a CEL envelope onvisibleis still preserved (objectui#9100).Ablation: restore the old collapse, with on-disk proof
Run with
node ../objectstack/scripts/ablation-replace.mjsin wrap mode (restore armed on exit, INT and TERM) ata8caa917. The anchor was the body ofisDataObjectValue, and the replacement prefixedfalse &&.anchor x1 → x0,replace x0 → x1,blob 41aaf62af3f1 → 7ec420a01564, "ok mutation landed".SchemaRenderer.predicateEnvelopeConfigBag,SchemaRenderer.enablementEnvelopeConfigBagandaction-enablement-cel-envelope):Tests 96 failed | 39 passed (135).blob after restore 41aaf62af3f1equalsblob at HEAD 41aaf62af3f1, andgit diff HEADis empty.17e6e0e9gave the same96 failed | 39 passed.vitest.config.mtsaliases@object-ui/reacttopackages/react/src.Tests and gates at
a8caa917(after mergingorigin/main4215ed76)pnpm exec vitest run --maxWorkers=2 packages/react/ packages/core/src/evaluator/ packages/components/src/renderers/action/, from the repo root through the verify lock:Test Files 130 passed (130),Tests 2003 passed (2003),VERDICT command-exit 0. This covers everySchemaRenderer*suite, theparamsBag/unevaluatedExpressionsuites,packages/core/src/evaluator/**and the objectui#7867 pinaction-params-templates-7867.@object-ui/componentsbuild closure was built first, thentype-check(tsc --noEmit && tsc -p tsconfig.test.json) passed for@object-ui/core,@object-ui/reactand@object-ui/components, withVERDICT command-exit 0.tsc -p tsconfig.test.json --listFilesOnlylists each new test file once.pnpm check:control-bytes: OK.pnpm check:new-line-citations: 0 new.node scripts/check-changeset-presence.mjs: passes, 1 changeset declared.pnpm changeset:check: passes.pnpm check:changeset-claimsnames 4 pending changesets that citeSchemaRenderer.tsx(5926, 7319, 7415, 9959). Each paragraph was re-read, and each describes a region this diff does not touch, so all four still hold.node scripts/check-doc-expression-carriage.mjs: exit 0, controls pass.pnpm check:component-surface-parity: exit 0 (report-only). Both parseSchemaRenderer.tsx.eslint --no-inline-configon the 3 touched files: 0 errors. The 19 warnings are all inSchemaRenderer.tsx, and the same count comes from the base blob of that file linted through stdin. This is a targeted run, NOT a narrowing claim: repo-widepnpm lintand the fullpnpm testfarm are NOT MEASURED locally and are left to CI.Changeset:
@object-ui/reactminor (Clause-②; corrected in round 2 atbe7515cb)It is a minor, not a patch. The PR declares
Clause-②: yes, and objectui's 版本号策略 releases its own behaviour-changing work asminorwith the semantics in the body. Round 1 declaredpatch. The contract review ofa8caa917failed that one item (code, pins, census and exports passed), and round 2 corrects it with a changeset-only commit.ExpressionEvaluator.evaluate's signature admits a dialect-less{ source }, and these loops handed it every value except aparamsbag and a CEL envelope on a predicate key. So{ source: '${…}' }on a config-bag key outside the predicate chain andparamsnow arrives as the object, uninterpolated, and the unevaluated-expression diagnostic does not report the${…}inside it. To keep such a value evaluated, write the bare string'${…}'or{ dialect: 'template', source: '${…}' }; both still interpolate on that path, and both are pinned as controls.dialect,@object-ui/core'sisRuntimeDefaultalready reads a dialect-less{ source }as a literal, and the census above found no author relying on the collapse.The
@object-ui/componentsfile in this diff is a test, and nothing it contains ships.Acceptance notes
ExpressionEvaluator.evaluatestill unwraps any{ source: string }for its direct callers. The caller census above finds none that passes a data object today, so it is left as is. Noted, not filed.{ source: '${…}' }written on a non-predicate key now arrives as data, and the unevaluated-expression diagnostic does not report the${…}inside it. That matches its stated radius (string values only, plusparamsleaves). Noted as a boundary, not a finding.ComponentRegistry.getAllConfigs()returns each plugin type twice (namespaced and bare), which is why the raw census has 122 rows for 80 pairs. Observation only.Draft by the dispatched
os-devrun for thedomain:uiseat #1, sessionsession_01BA3nKVUwKQJf8DBxrSVtNC(claim5819813547).Generated by Claude Code