From 8927ab5a895995d40dfdd5bae5535bfe688b6ba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:39:20 +0000 Subject: [PATCH 1/5] fix(react): hand a config-bag data object carrying a `source` field to 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 Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- packages/react/src/SchemaRenderer.tsx | 74 ++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 485c3b4172..a93573922e 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -472,6 +472,66 @@ const preservePredicateEnvelope = ( evaluate: (v: unknown) => unknown, ): unknown => (PREDICATE_CHAIN_KEYS.has(key) && isCelEnvelope(value) ? value : evaluate(value)); +/** + * Is this value an Expression envelope in the spec's sense: an object carrying + * a string `dialect` AND a string `source`? (objectui#10288) + * + * `@objectstack/spec`'s `ExpressionSchema` declares `dialect` REQUIRED, so every + * envelope a spec-parsed artifact carries has one. The shape test is the one + * `@object-ui/core` already applies to a declared `defaultValue` + * (`isRuntimeDefault`, "Same shape test the engine applies before handing a + * default to `ExpressionEngine`"), where an object with a `source` and no + * `dialect` is a literal value, not an instruction. {@link isCelEnvelope} is + * this question narrowed to one dialect. + */ +const isExpressionEnvelope = (value: unknown): boolean => + isConfigBag(value) + && typeof (value as { dialect?: unknown }).dialect === 'string' + && typeof (value as { source?: unknown }).source === 'string'; + +/** + * Is this config-bag value a DATA OBJECT, which the per-value loops hand to the + * renderer exactly as authored instead of passing it to + * `ExpressionEvaluator.evaluate`? (objectui#10288) + * + * ## The defect this closes + * + * `evaluate` unwraps ANY object with a string `source` to that bare string + * before it does anything else (see {@link preservePredicateEnvelope}). The + * loops handed it every value, so an authored data object that happened to + * carry a `source` field was silently replaced by that one string. Measured + * through the real `SchemaRenderer` -> `action:button` -> runner: + * `properties.bodyExtra: { source: 'web', campaign: 'spring' }` reached the + * handler as `bodyExtra: "web"`, while the same object written at node level + * (which no loop visits) arrived intact. So the canonical channel was the + * broken one. Every object-valued key a node carries in `properties` / `props` + * goes through the same loop, so the defect was never one key's: form + * `defaultValues`, a declarative `patch`, filter `values` and a detail view's + * `data` are all keyed by FIELD NAME, and a field named `source` is ordinary. + * + * ## What still reaches `evaluate` + * + * - every non-object value, exactly as before (`evaluate` interpolates a + * string and returns every other non-object untouched); + * - a spec Expression envelope ({@link isExpressionEnvelope}), so a + * `{ dialect: 'template', source: '${…}' }` value interpolates as it always + * did; + * - every value of a predicate-chain key ({@link PREDICATE_CHAIN_KEYS}), + * unchanged. `BaseSchema.visible` / `.hidden` / `.disabled` declare the + * dialect-less `{ source }` envelope as an authorable wire form + * (`ExpressionWire`, objectui#7530), so on those keys it stays an + * expression, and {@link preservePredicateEnvelope} still holds back a CEL + * envelope (objectui#9100 / #9107). + * + * So the only value whose meaning changes is an object on a non-predicate key + * with a string `source` and no string `dialect`, and that object now arrives + * whole. Nothing is walked: a template nested inside a data object stays raw, + * as it always did for an object without a `source`. The loops stay per-value + * and shallow, which is the radius the unevaluated-expression diagnostic reads. + */ +const isDataObjectValue = (key: string, value: unknown): boolean => + !PREDICATE_CHAIN_KEYS.has(key) && isConfigBag(value) && !isExpressionEnvelope(value); + /** * Which CONSEQUENCE the diagnostic should print for a faulting predicate on * this leg (objectui#6503). @@ -1218,10 +1278,16 @@ export const SchemaRenderer: ForwardRefExoticComponent< * text (that is the evaluator's own contract) and is reported by the * unevaluated-expression diagnostic, so a wrong template stays loud. */ + // + // A DATA OBJECT on any other key ({@link isDataObjectValue}, + // objectui#10288) is handed over as authored: `evaluate` would collapse + // one that carries a string `source` to that string. const evaluateConfigValue = (key: string, value: unknown): unknown => key === PARAMS_KEY && isParamsBag(value) ? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf)) - : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v as any)); + : isDataObjectValue(key, value) + ? value + : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v as any)); // Carrier 1 of the `params` rule above: the node-level bag. A non-bag // node-level `params` (the `ActionParam[]` definition list, or anything @@ -1264,6 +1330,12 @@ export const SchemaRenderer: ForwardRefExoticComponent< // is evaluated, through {@link evaluateConfigValue} above. Every other key // keeps the shallow reading this paragraph describes. // + // "Passed through" used to have one exception: `evaluate` unwraps an object + // with a string `source` to that string, so a data object carrying a + // `source` field was collapsed rather than passed. Since objectui#10288 an + // object on a non-predicate key reaches `evaluate` only when it is a spec + // Expression envelope (it carries a `dialect`); see {@link isDataObjectValue}. + // // Guarded by {@link isConfigBag}: a degenerate value must not have its shape // reinterpreted by an object spread. Non-objects skip evaluation and reach // the hoist — which, since objectui#6760, refuses them on its own. From 17e6e0e9f8d0d3a40edb77f2832540821abe6d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:41:29 +0000 Subject: [PATCH 2/5] test(react,components): pin config-bag data objects carrying `source` 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 Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...ction-config-bag-source-key-10288.test.tsx | 127 +++++++++++++++ ...enderer.configBagDataObject-10288.test.tsx | 145 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 packages/components/src/renderers/action/__tests__/action-config-bag-source-key-10288.test.tsx create mode 100644 packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx diff --git a/packages/components/src/renderers/action/__tests__/action-config-bag-source-key-10288.test.tsx b/packages/components/src/renderers/action/__tests__/action-config-bag-source-key-10288.test.tsx new file mode 100644 index 0000000000..8055bc4ae1 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-config-bag-source-key-10288.test.tsx @@ -0,0 +1,127 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10288: an authored data object that carries a `source` field must + * reach the runner WHOLE, whichever channel it is written on. + * + * `SchemaRenderer`'s per-value `properties` loop handed every value to + * `ExpressionEvaluator.evaluate`, whose first step unwraps ANY object with a + * string `source` to that bare string. So `properties.bodyExtra: { source: + * 'web', campaign: 'spring' }` reached the handler as `bodyExtra: "web"`. The + * same object written at node level is visited by no loop and always arrived + * intact: that is the CONTROL below, and it is what makes a red CASE mean the + * `properties` leg rather than an unforwarded key. + * + * Driven end to end through the REAL pieces, for the reason + * `action-params-templates-7867.test.tsx` gives: the real `SchemaRenderer` + * renders the real `action:button`, the click goes through the real + * `ActionRunner`, and the value asserted is read off the `ActionDef` the + * registered handler was handed. + * + * The predicate-key rows are the objectui#9100 / #9107 envelope protection on + * the SAME mount path. The fix narrows what the loop hands to `evaluate` on + * NON-predicate keys only, so these rows must stay green with the fix and with + * it ablated. Their predicate carries a CEL stdlib call (`has`), which the + * legacy engine lacks, so a flattened envelope cannot pass them by accident. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core'; +import type { DataSource } from '@object-ui/types'; +import { + ActionProvider, + PredicateScopeProvider, + SchemaRenderer, + SchemaRendererProvider, +} from '@object-ui/react'; +// Module-scope side-effect import so `action:button` is registered before the +// first render (the light `dom` project does not load the components graph). +// Module scope, not a `beforeAll`, per AGENTS.md 测试纪律. +import '../action-button'; + +/** An authored data object whose `source` is a FIELD, not an expression. */ +const BODY = { source: 'web', campaign: 'spring' }; + +const DATA = { status: 'draft' }; + +/** CEL stdlib call + a comparison. `has` does not exist on the legacy engine. */ +const HOLDS = { dialect: 'cel', source: 'has(data.status) && data.status == "draft"' }; +const FAILS = { dialect: 'cel', source: 'has(data.status) && data.status == "published"' }; + +let probe: Mock<(action: ActionDef, ctx: ActionContext) => Promise>; + +beforeEach(() => { + probe = vi.fn(async () => ({ success: true })); +}); + +afterEach(cleanup); + +/** + * Render one `action:button`. `DATA` is bound as the `data` root the way the + * sibling envelope pins bind it (see `action-enablement-cel-envelope.test.tsx` + * for why a plain object crosses the `DataSource` type here). + */ +function mount(node: Record) { + return render( + + + + + + + , + ); +} + +/** Click the one button and return the `ActionDef` the handler received. */ +async function defReceived(): Promise { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(probe).toHaveBeenCalledTimes(1)); + return probe.mock.calls[0][0] as ActionDef; +} + +describe('objectui#10288: a data object carrying `source` reaches the runner whole', () => { + it('CONTROL: node-level `bodyExtra` arrives intact (no loop visits it)', async () => { + mount({ bodyExtra: BODY }); + expect((await defReceived()).bodyExtra).toEqual(BODY); + }); + + it('CASE: `properties.bodyExtra` arrives intact, not collapsed to its `source` string', async () => { + mount({ properties: { bodyExtra: BODY } }); + const def = await defReceived(); + expect(def.bodyExtra).toEqual(BODY); + // The collapsed spelling, named so a red run reads as the defect itself. + expect(def.bodyExtra).not.toBe('web'); + }); +}); + +describe('objectui#9100 / #9107 CONTROL: a CEL envelope on a predicate key still gates this button', () => { + it('properties.visible: the holding row renders, the failing row does not', () => { + mount({ properties: { visible: HOLDS } }); + const holds = screen.queryByRole('button', { name: 'Send' }) !== null; + cleanup(); + mount({ properties: { visible: FAILS } }); + const fails = screen.queryByRole('button', { name: 'Send' }) !== null; + expect({ holds, fails }).toEqual({ holds: true, fails: false }); + }); + + it('properties.disabled: the holding row is disabled, the failing row is usable', () => { + mount({ properties: { disabled: HOLDS } }); + const holds = (screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled; + cleanup(); + mount({ properties: { disabled: FAILS } }); + const fails = (screen.getByRole('button', { name: 'Send' }) as HTMLButtonElement).disabled; + expect({ holds, fails }).toEqual({ holds: true, fails: false }); + }); +}); diff --git a/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx b/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx new file mode 100644 index 0000000000..ef8ea5aff1 --- /dev/null +++ b/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx @@ -0,0 +1,145 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10288: a DATA OBJECT in a node's config bag reaches the renderer + * whole, even when it carries a `source` field. + * + * The per-value `properties` / `props` loops handed every value to + * `ExpressionEvaluator.evaluate`, which unwraps ANY object with a string + * `source` to that bare string. The loops now hand an object on a + * non-predicate key to `evaluate` only when it is a spec Expression envelope + * (it carries a string `dialect`). The end-to-end probe (`action:button` -> + * runner) lives in `@object-ui/components`, as + * `action-config-bag-source-key-10288.test.tsx`; this file pins the fix site. + * + * ## The key table + * + * `CENSUS_KEYS` names the object-valued inputs found on this path when the card + * was worked: every registered input whose `type` carries an `object` arm, read + * off `ComponentRegistry.getAllConfigs()` with the whole registration graph + * loaded (`@object-ui/components` plus the console's `register-plugins`), plus + * the object-valued keys the action renderers forward off the node without + * declaring them as inputs. It is a SAMPLE of that census, not a live copy of + * it, and nothing here claims it is complete: the loop reads a key's NAME only + * to recognise `params` and the predicate chain, so the rule below holds for + * every other key, listed or not. The table exists so that each census row is + * named in a failure. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { DataSource } from '@object-ui/types'; +import { SchemaRenderer } from '../SchemaRenderer'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; +import { PredicateScopeProvider } from '../hooks/useExpression'; + +const CENSUS_KEYS = [ + // Registered inputs with an `object` arm. + 'action', 'actions', 'add', 'aggregate', 'body', 'compareTo', 'content', 'data', + 'dataSource', 'dateRange', 'defaultValues', 'description', 'drillDown', + 'emptyText', 'exportOptions', 'feed', 'footer', 'grouping', 'header', + 'initialData', 'initialValues', 'label', 'listViews', 'metrics', 'mobile', + 'navigation', 'operations', 'options', 'pagination', 'placeholder', + 'rightContent', 'rowColor', 'search', 'selection', 'submitBehavior', + 'subtitle', 'systemActions', 'title', 'trend', 'value', 'values', + // Object-valued keys `action:button` / `action:icon` forward off the node. + 'bodyExtra', 'bodyShape', 'patch', 'resultDialog', 'onSuccess', 'toast', +]; + +/** A data object whose `source` is a FIELD, not an expression. */ +const BODY = { source: 'web', campaign: 'spring' }; + +const DATA = { status: 'draft' }; + +/** The schema the probe was last handed: what a renderer actually reads. */ +let seen: Record | undefined; + +const Probe = (props: { schema?: Record }) => { + seen = props.schema; + return
; +}; + +/** + * `DATA` is injected as the `data` ROOT of the expression scope, not as an + * adapter; see the objectui#7912 note in + * `SchemaRenderer.predicateEnvelopeConfigBag.test.tsx` for why it crosses the + * `DataSource` type. + */ +function mount(schema: Record) { + return render( + + + + + , + ); +} + +beforeEach(() => { + seen = undefined; + ComponentRegistry.register('probe-10288', Probe as never); +}); + +afterEach(() => { + cleanup(); + ComponentRegistry.unregister?.('probe-10288'); +}); + +describe('objectui#10288: a data object carrying `source` is not collapsed to that string', () => { + it.each(CENSUS_KEYS)('properties.%s arrives whole, in the bag and on the node', (key) => { + mount({ properties: { [key]: BODY } }); + expect(seen?.properties?.[key]).toEqual(BODY); + // The hoist copies the evaluated value onto the node, which is where most + // renderers read it. + expect(seen?.[key]).toEqual(BODY); + }); + + it.each(CENSUS_KEYS)('props.%s arrives whole', (key) => { + mount({ props: { [key]: BODY } }); + expect(seen?.props?.[key]).toEqual(BODY); + }); + + it('a dialect-less `{ source }` on a non-predicate key is DATA now, and is not interpolated', () => { + // The behaviour change, stated as a pin: the spec's Expression envelope + // always carries `dialect`, so this object is a value, like the server's + // `defaultValue` reading of the same shape. + const authored = { source: '${data.status}' }; + mount({ properties: { caption: authored } }); + expect(seen?.properties?.caption).toEqual(authored); + }); +}); + +describe('objectui#10288 CONTROLS: what the loops still evaluate', () => { + it('a string template is still interpolated', () => { + mount({ properties: { caption: 'S-${data.status}' } }); + expect(seen?.properties?.caption).toBe('S-draft'); + }); + + it('a spec `template` envelope on a non-predicate key still collapses to its interpolated value', () => { + mount({ properties: { caption: { dialect: 'template', source: '${data.status}' } } }); + expect(seen?.properties?.caption).toBe('draft'); + }); + + it('a dialect-less `{ source }` on a PREDICATE key is still an expression (ExpressionWire, objectui#7530)', () => { + mount({ properties: { visible: { source: '${data.status === "draft"}' } } }); + const holds = screen.queryByTestId('probe') !== null; + cleanup(); + mount({ properties: { visible: { source: '${data.status === "published"}' } } }); + const fails = screen.queryByTestId('probe') !== null; + expect({ holds, fails }).toEqual({ holds: true, fails: false }); + }); + + it('a CEL envelope on a predicate key is still preserved, not flattened (objectui#9100)', () => { + const envelope = { dialect: 'cel', source: 'has(data.status) && data.status == "draft"' }; + mount({ properties: { visible: envelope } }); + expect(seen?.properties?.visible).toEqual(envelope); + }); +}); From bf5dbb46d884092d2f2c48cce95bf0c6bb55e5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:42:57 +0000 Subject: [PATCH 3/5] chore(react): changeset and pin pointers for the config-bag data-object 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 Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../10288-config-bag-data-object-source.md | 20 +++++++++++++++++++ packages/react/src/SchemaRenderer.tsx | 10 +++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/10288-config-bag-data-object-source.md diff --git a/.changeset/10288-config-bag-data-object-source.md b/.changeset/10288-config-bag-data-object-source.md new file mode 100644 index 0000000000..bf11bd371a --- /dev/null +++ b/.changeset/10288-config-bag-data-object-source.md @@ -0,0 +1,20 @@ +--- +'@object-ui/react': patch +--- + +fix(react): a data object in a node's `properties` / `props` bag reaches the renderer whole, even when it carries a `source` field + +`SchemaRenderer` evaluates each value of a node's config bag (`properties`, and its legacy alias `props`) one by one, and handed every value to `ExpressionEvaluator.evaluate`. That call unwraps ANY object with a string `source` to that bare string before it does anything else. So an authored data object that merely had a field called `source` was silently replaced by that one string: an `action:button` with `properties.bodyExtra: { source: 'web', campaign: 'spring' }` sent `"web"` to the action runner instead of the object, with no error and no warning. The same object written at node level, which no loop visits, always arrived intact. + +This was never one key's problem. Every object-valued input on those two bags took the same path, including values keyed by field name: form `defaultValues` / `initialValues`, a declarative `patch`, filter `values` and a detail view's `data`, where a field named `source` is ordinary. + +On a key outside the predicate chain, an object now reaches `evaluate` only when it is an Expression envelope in `@objectstack/spec`'s sense, meaning it carries a string `dialect` (the spec's `ExpressionSchema` requires one). Every other object is handed to the renderer exactly as authored. + +**Behaviour change, stated plainly.** The only value whose meaning changes is an object on a non-predicate config-bag key that has a string `source` and no string `dialect`. It used to collapse to its evaluated `source`; it now arrives as the object. Everything else evaluates as before: + +- strings are still interpolated; +- a `{ dialect: 'template', source: '${…}' }` envelope still resolves to its value; +- the predicate keys (`visible`, `visibleWhen`, `visibleOn`, `visibility`, `hidden`, `hiddenOn`, `disabled`, `disabledOn`, `enabled`) are unchanged: a CEL envelope is still preserved for the canonical engine (objectui#9100 / #9107), and a dialect-less `{ source }` on them is still evaluated, the form `ExpressionWire` declares for `visible` / `hidden` / `disabled`; +- `params` is still walked leaf by leaf (objectui#7867). + +Declared as a patch rather than a minor: nothing in the published API is added or removed, and the shape whose reading changes was never declared as an expression on these keys. The spec's envelope always carries `dialect`, and `@object-ui/core`'s `isRuntimeDefault` already reads a dialect-less `{ source }` as a literal value. diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index a93573922e..41aaf62af3 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -504,15 +504,19 @@ const isExpressionEnvelope = (value: unknown): boolean => * `properties.bodyExtra: { source: 'web', campaign: 'spring' }` reached the * handler as `bodyExtra: "web"`, while the same object written at node level * (which no loop visits) arrived intact. So the canonical channel was the - * broken one. Every object-valued key a node carries in `properties` / `props` + * broken one. That pair is pinned end to end in `@object-ui/components` + * (`action-config-bag-source-key-10288.test.tsx`), and this guard by + * `__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx`. + * Every object-valued key a node carries in `properties` / `props` * goes through the same loop, so the defect was never one key's: form * `defaultValues`, a declarative `patch`, filter `values` and a detail view's * `data` are all keyed by FIELD NAME, and a field named `source` is ordinary. * * ## What still reaches `evaluate` * - * - every non-object value, exactly as before (`evaluate` interpolates a - * string and returns every other non-object untouched); + * - every value that is not an object in {@link isConfigBag}'s sense (a + * string, any other primitive, an array), exactly as before: `evaluate` + * interpolates a string and returns the rest untouched; * - a spec Expression envelope ({@link isExpressionEnvelope}), so a * `{ dialect: 'template', source: '${…}' }` value interpolates as it always * did; From e8b7628326977b74cf172f052de576ee65518b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:50:38 +0000 Subject: [PATCH 4/5] test(react): capture the probe's schema through a spy, not a reassigned 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 Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- ...enderer.configBagDataObject-10288.test.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx b/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx index ef8ea5aff1..cdd6071402 100644 --- a/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx +++ b/packages/react/src/__tests__/SchemaRenderer.configBagDataObject-10288.test.tsx @@ -32,7 +32,7 @@ * named in a failure. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, cleanup } from '@testing-library/react'; import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; @@ -59,14 +59,23 @@ const BODY = { source: 'web', campaign: 'spring' }; const DATA = { status: 'draft' }; -/** The schema the probe was last handed: what a renderer actually reads. */ -let seen: Record | undefined; +/** The evaluated schema a renderer is handed: the keys this file reads off it. */ +type Seen = Record & { + properties?: Record; + props?: Record; +}; + +/** Records every schema the probe is handed; the last one is what it rendered. */ +const handed = vi.fn<(schema: Seen | undefined) => void>(); -const Probe = (props: { schema?: Record }) => { - seen = props.schema; +const Probe = (props: { schema?: Seen }) => { + handed(props.schema); return
; }; +/** The schema the probe was last handed. */ +const seen = (): Seen | undefined => handed.mock.lastCall?.[0]; + /** * `DATA` is injected as the `data` ROOT of the expression scope, not as an * adapter; see the objectui#7912 note in @@ -84,7 +93,7 @@ function mount(schema: Record) { } beforeEach(() => { - seen = undefined; + handed.mockClear(); ComponentRegistry.register('probe-10288', Probe as never); }); @@ -96,15 +105,15 @@ afterEach(() => { describe('objectui#10288: a data object carrying `source` is not collapsed to that string', () => { it.each(CENSUS_KEYS)('properties.%s arrives whole, in the bag and on the node', (key) => { mount({ properties: { [key]: BODY } }); - expect(seen?.properties?.[key]).toEqual(BODY); + expect(seen()?.properties?.[key]).toEqual(BODY); // The hoist copies the evaluated value onto the node, which is where most // renderers read it. - expect(seen?.[key]).toEqual(BODY); + expect(seen()?.[key]).toEqual(BODY); }); it.each(CENSUS_KEYS)('props.%s arrives whole', (key) => { mount({ props: { [key]: BODY } }); - expect(seen?.props?.[key]).toEqual(BODY); + expect(seen()?.props?.[key]).toEqual(BODY); }); it('a dialect-less `{ source }` on a non-predicate key is DATA now, and is not interpolated', () => { @@ -113,19 +122,19 @@ describe('objectui#10288: a data object carrying `source` is not collapsed to th // `defaultValue` reading of the same shape. const authored = { source: '${data.status}' }; mount({ properties: { caption: authored } }); - expect(seen?.properties?.caption).toEqual(authored); + expect(seen()?.properties?.caption).toEqual(authored); }); }); describe('objectui#10288 CONTROLS: what the loops still evaluate', () => { it('a string template is still interpolated', () => { mount({ properties: { caption: 'S-${data.status}' } }); - expect(seen?.properties?.caption).toBe('S-draft'); + expect(seen()?.properties?.caption).toBe('S-draft'); }); it('a spec `template` envelope on a non-predicate key still collapses to its interpolated value', () => { mount({ properties: { caption: { dialect: 'template', source: '${data.status}' } } }); - expect(seen?.properties?.caption).toBe('draft'); + expect(seen()?.properties?.caption).toBe('draft'); }); it('a dialect-less `{ source }` on a PREDICATE key is still an expression (ExpressionWire, objectui#7530)', () => { @@ -140,6 +149,6 @@ describe('objectui#10288 CONTROLS: what the loops still evaluate', () => { it('a CEL envelope on a predicate key is still preserved, not flattened (objectui#9100)', () => { const envelope = { dialect: 'cel', source: 'has(data.status) && data.status == "draft"' }; mount({ properties: { visible: envelope } }); - expect(seen?.properties?.visible).toEqual(envelope); + expect(seen()?.properties?.visible).toEqual(envelope); }); }); From be7515cb7646b2f77164adfb63384ed94c0bb456 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 19:26:35 +0000 Subject: [PATCH 5/5] chore(changeset): declare the config-bag data-object fix as a minor 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 Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/10288-config-bag-data-object-source.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/10288-config-bag-data-object-source.md b/.changeset/10288-config-bag-data-object-source.md index bf11bd371a..0021a08075 100644 --- a/.changeset/10288-config-bag-data-object-source.md +++ b/.changeset/10288-config-bag-data-object-source.md @@ -1,5 +1,5 @@ --- -'@object-ui/react': patch +'@object-ui/react': minor --- fix(react): a data object in a node's `properties` / `props` bag reaches the renderer whole, even when it carries a `source` field @@ -17,4 +17,4 @@ On a key outside the predicate chain, an object now reaches `evaluate` only when - the predicate keys (`visible`, `visibleWhen`, `visibleOn`, `visibility`, `hidden`, `hiddenOn`, `disabled`, `disabledOn`, `enabled`) are unchanged: a CEL envelope is still preserved for the canonical engine (objectui#9100 / #9107), and a dialect-less `{ source }` on them is still evaluated, the form `ExpressionWire` declares for `visible` / `hidden` / `disabled`; - `params` is still walked leaf by leaf (objectui#7867). -Declared as a patch rather than a minor: nothing in the published API is added or removed, and the shape whose reading changes was never declared as an expression on these keys. The spec's envelope always carries `dialect`, and `@object-ui/core`'s `isRuntimeDefault` already reads a dialect-less `{ source }` as a literal value. +Declared as a minor (Clause-②): nothing in the published API is added or removed, but what an authored value evaluates to changes for one shape. `ExpressionEvaluator.evaluate`'s signature admits a dialect-less `{ source }`, and these loops handed it every value except a `params` bag and a CEL envelope on a predicate key. So `{ source: '${…}' }` on a config-bag key outside the predicate chain and `params` now 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. The spec's envelope always carries `dialect`, and `@object-ui/core`'s `isRuntimeDefault` already reads a dialect-less `{ source }` as a literal value.