From 71eabbf0468c5a71ba986b6f0d7bd2fc1521c9f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:43:43 +0000 Subject: [PATCH 1/5] fix(components): action container members evaluate properties.params like a top-level action:button (objectui#10290) A member of action:bar, action:group or action:menu is drawn by its container and never passes through SchemaRenderer, so the evaluation that resolves `${record.id}` inside properties.params never ran for it. The bar's inline members reached the handler with the raw template, and overflow, group and menu items did not forward properties.params at all. Each container now evaluates its members' properties with the SchemaRenderer memo's own per-key rule and scope, exported from @object-ui/react as useConfigBagEvaluator(). action:bar evaluates an inline member where it composes it; action:group and action:menu evaluate an item when they run it, so an overflow member is evaluated once. A node-level params object is still not read as values. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...10290-container-member-params-evaluated.md | 27 +++ ...ion-container-member-params-10290.test.tsx | 199 ++++++++++++++++++ .../src/renderers/action/action-bar.tsx | 16 +- .../src/renderers/action/action-group.tsx | 24 ++- .../src/renderers/action/action-menu.tsx | 24 ++- .../src/renderers/action/static-params.ts | 69 +++++- packages/react/src/SchemaRenderer.tsx | 132 ++++++++++-- 7 files changed, 450 insertions(+), 41 deletions(-) create mode 100644 .changeset/10290-container-member-params-evaluated.md create mode 100644 packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx diff --git a/.changeset/10290-container-member-params-evaluated.md b/.changeset/10290-container-member-params-evaluated.md new file mode 100644 index 0000000000..84787ddbe3 --- /dev/null +++ b/.changeset/10290-container-member-params-evaluated.md @@ -0,0 +1,27 @@ +--- +'@object-ui/components': patch +'@object-ui/react': patch +--- + +fix(components): an action container's members evaluate `properties.params` the way a top-level `action:button` does + +A member of `action:bar`, `action:group` or `action:menu` never passes through +`SchemaRenderer`, so the evaluation that resolves `"recordId": "${record.id}"` +in a node's `properties.params` did not run for it. On a record page an +`action:bar` member drawn inline handed its `navigate_edit` handler the raw +`${record.id}` text, and a member that the bar placed in its overflow menu, or +an `action:group` / `action:menu` item, did not forward its `properties.params` +at all. + +Each container now evaluates its members' `properties` with the evaluator and +scope `SchemaRenderer` uses: `action:bar` where it composes an inline member, +and `action:group` / `action:menu` when they run an item. A container member's +static values therefore reach the handler exactly as a top-level +`action:button`'s do. An item that carries an `ActionParam[]` input list +forwards both, as `action:button` does. A node-level `params` object is still +not read as values (objectui#10289). + +`@object-ui/react` exports `useConfigBagEvaluator()` for this. It returns the +`SchemaRenderer` memo's own `properties` evaluation (the same per-key rule and +the same `record` / `current_user` / `page` scope) for a node rendered without +`SchemaRenderer`. It is not a second template engine. diff --git a/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx new file mode 100644 index 0000000000..df7f621d42 --- /dev/null +++ b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx @@ -0,0 +1,199 @@ +/** + * 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#10290: a member of an action CONTAINER (`action:bar`, + * `action:group`, `action:menu`) carries its static execution values in + * `properties.params` (objectui#10289, ruling A), and those values are + * templates evaluated where `properties` are (objectui#7867, ruling A). A + * container member never passes through the `SchemaRenderer` evaluation memo, + * so each container evaluates its members' `properties` through the evaluator + * that memo uses, against the scope that memo builds. + * + * Driven end to end through the real pieces: the real `SchemaRenderer` renders + * the real container, the click or menu selection goes through the real + * `ActionRunner`, and the value asserted is the `params` on the `ActionDef` the + * registered `navigate_edit` handler receives. The row is bound the way a + * record page binds it, through `RecordContextProvider`. + * + * The CONTROL is a top-level `action:button` with the same `properties.params`. + * It goes through the `SchemaRenderer` memo, so it resolved before this change + * too: a red container row beside a green control means the container, never + * an unbound `record` root. + */ + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core'; +import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react'; +// Module-scope side-effect imports: `action:bar` resolves its members and its +// overflow menu through the ComponentRegistry at render time, and the light +// `dom` project does not load the components graph. Module scope, not a +// `beforeAll`, per AGENTS.md 测试纪律. +import '../action-bar'; +import '../action-button'; +import '../action-icon'; +import '../action-menu'; +import '../action-group'; +import { resetStaticParamsWarnings } from '../static-params'; + +const ROW = { id: 'rec_1', name: 'Acme' }; + +/** The authored values: one template leaf, one literal leaf. */ +const AUTHORED = { objectName: 'account', recordId: '${record.id}' }; + +/** What the handler must receive once the template is evaluated. */ +const RESOLVED = { objectName: 'account', recordId: 'rec_1' }; + +/** The overflow trigger's accessible name, with no i18n bundle loaded. */ +const MORE = 'More actions'; + +let navigateEdit: Mock<(action: ActionDef, ctx: ActionContext) => Promise>; +let warn: ReturnType; +let consoleError: ReturnType; + +beforeEach(() => { + navigateEdit = vi.fn(async () => ({ success: true })); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + resetStaticParamsWarnings(); +}); + +afterEach(() => { + warn.mockRestore(); + consoleError.mockRestore(); +}); + +/** One member action, as an author writes it inside a container's `actions`. */ +function member(extra: Record = {}): Record { + return { name: 'edit', label: 'Edit', type: 'navigate_edit', ...extra }; +} + +/** Render `schema` on a record page bound to {@link ROW}. */ +function renderOnRecordPage(schema: Record) { + return render( + + + + + , + ); +} + +/** Radix opens a dropdown on `pointerdown` and mounts its content in a portal. */ +async function selectFromMenu(trigger: HTMLElement, label: string) { + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }); + const menu = await screen.findByRole('menu'); + fireEvent.click(within(menu).getByRole('menuitem', { name: label })); +} + +/** How the member is reached once its container is on screen. */ +type Reach = (label: string) => Promise; + +const clickButton: Reach = async (label) => { + fireEvent.click(screen.getByRole('button', { name: label })); +}; + +const viaTrigger = (triggerName: string): Reach => async (label) => { + await selectFromMenu(screen.getByRole('button', { name: triggerName }), label); +}; + +/** Every path a container member reaches the runner by. */ +const CONTAINERS: ReadonlyArray<{ + path: string; + schema: (m: Record) => Record; + reach: Reach; +}> = [ + { + path: 'action:bar, inline member', + schema: (m) => ({ type: 'action:bar', actions: [m] }), + reach: clickButton, + }, + { + path: 'action:bar, member drawn as an icon (`component: action:icon`)', + schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:icon' }] }), + reach: clickButton, + }, + { + path: 'action:bar, member placed in a button group (`component: action:group`)', + schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:group' }] }), + reach: clickButton, + }, + { + path: 'action:bar, member placed in the overflow menu (`component: action:menu`)', + schema: (m) => ({ type: 'action:bar', actions: [{ ...m, component: 'action:menu' }] }), + reach: viaTrigger(MORE), + }, + { + path: 'action:bar, member spilled past `maxVisible` into the overflow menu', + schema: (m) => ({ + type: 'action:bar', + maxVisible: 1, + mobileMaxVisible: 1, + actions: [{ name: 'first', label: 'First', type: 'navigate_edit' }, m], + }), + reach: viaTrigger(MORE), + }, + { + path: 'action:group, inline display', + schema: (m) => ({ type: 'action:group', actions: [m] }), + reach: clickButton, + }, + { + path: 'action:group, dropdown display', + schema: (m) => ({ type: 'action:group', display: 'dropdown', label: 'Group', actions: [m] }), + reach: viaTrigger('Group'), + }, + { + path: 'action:menu', + schema: (m) => ({ type: 'action:menu', actions: [m] }), + reach: viaTrigger(MORE), + }, +]; + +/** Mount, reach the member, return the `params` the handler received. */ +async function paramsReceived(schema: Record, reach: Reach): Promise { + renderOnRecordPage(schema); + await reach('Edit'); + await waitFor(() => expect(navigateEdit).toHaveBeenCalledTimes(1)); + return (navigateEdit.mock.calls[0][0] as ActionDef).params; +} + +describe('objectui#10290 - a container member\'s `properties.params` is evaluated where `properties` are', () => { + it('CONTROL: a top-level `action:button` resolves `${record.id}` through the SchemaRenderer memo', async () => { + const params = await paramsReceived( + { + type: 'action:button', + name: 'edit', + label: 'Edit', + actionType: 'navigate_edit', + properties: { params: AUTHORED }, + }, + clickButton, + ); + expect(params).toEqual(RESOLVED); + }); + + describe.each(CONTAINERS)('$path', ({ schema, reach }) => { + it('the handler receives `properties.params` with `${record.id}` resolved', async () => { + const params = await paramsReceived(schema(member({ properties: { params: AUTHORED } })), reach); + expect(params).toEqual(RESOLVED); + }); + + it('a node-level `params` OBJECT is still not a values channel, and says so once (objectui#10289)', async () => { + const params = await paramsReceived(schema(member({ params: AUTHORED })), reach); + expect(params).toBeUndefined(); + const lines = warn.mock.calls + .map((c: unknown[]) => String(c[0])) + .filter((m: string) => m.includes('action "edit"') && m.includes('`params`')); + expect(lines).toHaveLength(1); + }); + }); +}); diff --git a/packages/components/src/renderers/action/action-bar.tsx b/packages/components/src/renderers/action/action-bar.tsx index dd60d8a9d2..34d3f68888 100644 --- a/packages/components/src/renderers/action/action-bar.tsx +++ b/packages/components/src/renderers/action/action-bar.tsx @@ -51,11 +51,12 @@ import React, { forwardRef, useMemo } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { UIActionSchema, ActionLocation, ActionComponent } from '@object-ui/types'; import { ACTION_LOCATIONS, actionRendersAt } from '@object-ui/types'; -import { useCondition, toPredicateInput, useCapabilityGate } from '@object-ui/react'; +import { useCondition, toPredicateInput, useCapabilityGate, useConfigBagEvaluator } from '@object-ui/react'; import { useObjectTranslation } from '@object-ui/i18n'; import { cn } from '../../lib/utils'; import { useIsMobile } from '../../hooks/use-mobile'; import { ButtonGroup } from '../../custom/button-group'; +import { withEvaluatedProperties } from './static-params'; function useActionsLabel(): string { // useObjectTranslation is provider-safe (never throws); no try/catch, which @@ -157,6 +158,9 @@ const ActionBarRenderer = forwardRef { @@ -327,6 +331,14 @@ const ActionBarRenderer = forwardRef { const Renderer = ComponentRegistry.get(componentType); if (!Renderer) return null; @@ -335,7 +347,7 @@ const ActionBarRenderer = forwardRef | undefined { + const properties = subject.properties; + return isConfigBag(properties) && isConfigBag(properties.params) + ? (properties.params as Record) + : undefined; +} + /** * The static values to forward as the runner's `ActionDef.params`: the node's * `properties.params` when it is an object bag, otherwise `undefined`. @@ -82,11 +90,7 @@ export function readStaticParamValues( subject: StaticParamsSubject, where: string, ): Record | undefined { - const properties = subject.properties; - const values = - isConfigBag(properties) && isConfigBag(properties.params) - ? (properties.params as Record) - : undefined; + const values = propertiesParams(subject); const nodeLevel = subject.params; if (isConfigBag(nodeLevel) && nodeLevel !== values) { warnNodeLevelObjectParams(subject, where); @@ -94,11 +98,62 @@ export function readStaticParamValues( return values; } +/** + * Evaluates a config bag the way `SchemaRenderer` evaluates `properties`: the + * function `useConfigBagEvaluator()` (`@object-ui/react`) returns. + */ +export type ConfigBagEvaluator = (bag: unknown) => unknown; + +/** + * A container MEMBER with its `properties` evaluated (objectui#10290). + * + * `action:bar`, `action:group` and `action:menu` draw their member actions + * themselves, so a member never passes through the `SchemaRenderer` memo that + * evaluates a node's `properties`. Its static values ride `properties.params` + * (objectui#10289), and they are templates (objectui#7867): on a record page, + * `"recordId": "${record.id}"` has to reach the handler as the record's id. The + * container evaluates the member's `properties` with the memo's own evaluator + * and scope (`evaluateBag`, from `useConfigBagEvaluator()`), once, where it + * composes or runs the member. + * + * Returns the member itself when it carries no `properties` bag. Only + * `properties` is evaluated. It is not copied onto the member (the memo's + * hoist), and the member's other keys are left as authored. + */ +export function withEvaluatedProperties( + member: T, + evaluateBag: ConfigBagEvaluator, +): T { + return isConfigBag(member.properties) + ? { ...member, properties: evaluateBag(member.properties) } + : member; +} + +/** + * A container member's static values (objectui#10290): its `properties.params`, + * evaluated through {@link withEvaluatedProperties}, or `undefined`. + * + * For `action:group` / `action:menu` items, which run the member themselves. + * (`action:bar` hands an inline member to `action:button` / `action:icon` with + * its `properties` already evaluated, and `readStaticParamValues` reads it + * there.) + */ +export function readMemberStaticParamValues( + member: StaticParamsSubject, + evaluateBag: ConfigBagEvaluator, +): Record | undefined { + if (!propertiesParams(member)) return undefined; + return propertiesParams(withEvaluatedProperties(member, evaluateBag)); +} + /** * The same ruling on a spec ACTION ENTRY (objectui#10462): `element:button`'s * inline `action`, `action:group` / `action:menu` items and `page:header`'s - * actions. An entry is not an SDUI node, so it has no `properties.params` bag; - * `params` is its `ActionParam[]` input list and nothing else. + * actions. `params` is an entry's `ActionParam[]` input list and nothing else. + * (An `action:group` / `action:menu` item is also a container member, so its + * static values ride `properties.params`, read by + * {@link readMemberStaticParamValues} — objectui#10290. `element:button`'s + * inline `action` and `page:header`'s actions do not read `properties.params`.) * * One type is still different: `api`. The objectstack#5777 window keeps the * runner reading an object `params` as the request payload (with its own diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index 364d47f781..cf6e2ceb01 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -536,6 +536,97 @@ const isExpressionEnvelope = (value: unknown): boolean => const isDataObjectValue = (key: string, value: unknown): boolean => !PREDICATE_CHAIN_KEYS.has(key) && isConfigBag(value) && !isExpressionEnvelope(value); +/** + * The scope the evaluation memo in {@link SchemaRenderer} evaluates a node's + * values against: the ambient host scope, `current_user` aliased to `user`, the + * page's bound row as `record` (only when there is one), and page variables as + * `page`. Why each root is (and `data` is not) bound is stated where the memo + * builds its evaluator from this. + * + * One function, so the memo and {@link useConfigBagEvaluator} cannot build two + * different scopes (objectui#10290). + */ +const configEvaluationScope = ( + predicateScope: Record, + boundRecord: unknown, + pageVariables: unknown, +): Record => ({ + ...predicateScope, + current_user: (predicateScope as any)?.user, + ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord) + ? { record: boundRecord } + : null), + page: pageVariables, +}); + +/** + * Evaluate ONE config value by its key. This is the per-key rule of the + * evaluation memo (the `params` rule, objectui#7867, is stated there): a + * `params` bag has every string leaf evaluated, a data object is handed over as + * authored, and every other value is evaluated per value and shallow, with a + * CEL predicate envelope preserved. + */ +const evaluateConfigValueWith = ( + evaluator: ExpressionEvaluator, + key: string, + value: unknown, +): unknown => + key === PARAMS_KEY && isParamsBag(value) + ? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf)) + : isDataObjectValue(key, value) + ? value + : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v as any)); + +/** + * Evaluate every value of a config bag with {@link evaluateConfigValueWith}. + * The memo's `properties` loop, as a function. Returns a new object; the + * authored bag is never mutated. + */ +const evaluateConfigBagWith = ( + evaluator: ExpressionEvaluator, + bag: Record, +): Record => { + const evaluated: Record = { ...bag }; + for (const [key, val] of Object.entries(evaluated)) { + evaluated[key] = evaluateConfigValueWith(evaluator, key, val); + } + return evaluated; +}; + +/** + * The `properties` evaluation of the {@link SchemaRenderer} memo, for a node + * that is rendered WITHOUT `SchemaRenderer` (objectui#10290). + * + * The action containers (`action:bar`, `action:group`, `action:menu` in + * `@object-ui/components`) draw their member actions themselves, so a member's + * `properties` never passes through the memo. Its static execution values ride + * `properties.params` (objectui#10289), and those values are templates + * evaluated where `properties` are (objectui#7867). This hook is that + * evaluation: the same per-key rule, the same `ExpressionEvaluator`, and the + * same scope, read from the same contexts the memo reads. It is not a second + * template engine. + * + * The returned function takes a config bag and returns a new, evaluated bag. A + * value that is not a config bag is returned unchanged. Call it once per + * authored bag: the result is already evaluated, and evaluating it again would + * interpolate text that came out of the row. + * + * A fresh function each render. Nothing may depend on its identity + * (AGENTS.md #10). + */ +export function useConfigBagEvaluator(): (bag: unknown) => unknown { + const predicateScope = usePredicateScope(); + const boundRecord = useRecordContext()?.data; + const { variables: pageVariables } = usePageVariables(); + return (bag: unknown): unknown => { + if (!isConfigBag(bag)) return bag; + const evaluator = new ExpressionEvaluator( + configEvaluationScope(predicateScope, boundRecord, pageVariables), + ); + return evaluateConfigBagWith(evaluator, bag as Record); + }; +} + /** * Which CONSEQUENCE the diagnostic should print for a faulting predicate on * this leg (objectui#6503). @@ -1005,14 +1096,13 @@ export const SchemaRenderer: ForwardRefExoticComponent< // (how `action:group`'s dropdown leaf is driven), turning "this surface has // no row of its own" into "this surface's row is empty" — only the latter // is entitled to shadow. - const evaluator = new ExpressionEvaluator({ - ...predicateScope, - current_user: (predicateScope as any)?.user, - ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord) - ? { record: boundRecord } - : null), - page: pageVariables, - }); + // + // Built by `configEvaluationScope`, which `useConfigBagEvaluator` shares, so + // an action container's members evaluate against this same scope + // (objectui#10290). + const evaluator = new ExpressionEvaluator( + configEvaluationScope(predicateScope, boundRecord, pageVariables), + ); // Shallow copy const newSchema = { ...schema }; @@ -1310,12 +1400,12 @@ export const SchemaRenderer: ForwardRefExoticComponent< // 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. + // + // The rule itself is `evaluateConfigValueWith`, at module scope, so that + // `useConfigBagEvaluator` applies the same one to an action container's + // members (objectui#10290). const evaluateConfigValue = (key: string, value: unknown): unknown => - key === PARAMS_KEY && isParamsBag(value) - ? mapParamsLeaves(value, (leaf) => evaluator.evaluate(leaf)) - : isDataObjectValue(key, value) - ? value - : preservePredicateEnvelope(key, value, (v) => evaluator.evaluate(v as any)); + evaluateConfigValueWith(evaluator, key, value); // Carrier 1 of the `params` rule above: the node-level bag. A non-bag // node-level `params` (the `ActionParam[]` definition list, or anything @@ -1400,15 +1490,13 @@ export const SchemaRenderer: ForwardRefExoticComponent< : undefined; if (rawPropertiesBag) { - const newProperties: Record = { ...rawPropertiesBag }; - for (const [key, val] of Object.entries(newProperties)) { - // objectui#9100 — a CEL predicate envelope survives this loop; see - // `preservePredicateEnvelope`. objectui#7867 — a `params` bag has every - // string leaf evaluated (carrier 2 of `evaluateConfigValue`). Every - // other value evaluates as before. - newProperties[key] = evaluateConfigValue(key, val); - } - newSchema.properties = newProperties; + // objectui#9100 — a CEL predicate envelope survives this loop; see + // `preservePredicateEnvelope`. objectui#7867 — a `params` bag has every + // string leaf evaluated (carrier 2 of `evaluateConfigValue`). Every + // other value evaluates as before. The loop is `evaluateConfigBagWith`, + // the one `useConfigBagEvaluator` runs over an action container's + // member (objectui#10290). + newSchema.properties = evaluateConfigBagWith(evaluator, rawPropertiesBag); } /** From 570de551d204c66ad1359c241cc394396d940da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:46:25 +0000 Subject: [PATCH 2/5] fix(components): withEvaluatedProperties takes any member object (TS2559 on UIActionSchema) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/components/src/renderers/action/static-params.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/components/src/renderers/action/static-params.ts b/packages/components/src/renderers/action/static-params.ts index 528270b61e..f3786f844b 100644 --- a/packages/components/src/renderers/action/static-params.ts +++ b/packages/components/src/renderers/action/static-params.ts @@ -120,13 +120,12 @@ export type ConfigBagEvaluator = (bag: unknown) => unknown; * `properties` is evaluated. It is not copied onto the member (the memo's * hoist), and the member's other keys are left as authored. */ -export function withEvaluatedProperties( +export function withEvaluatedProperties( member: T, evaluateBag: ConfigBagEvaluator, ): T { - return isConfigBag(member.properties) - ? { ...member, properties: evaluateBag(member.properties) } - : member; + const properties = (member as { properties?: unknown }).properties; + return isConfigBag(properties) ? { ...member, properties: evaluateBag(properties) } : member; } /** From a60555f057a3e7d87a940e2f95496e2050c89261 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:56:48 +0000 Subject: [PATCH 3/5] refactor(react): useConfigBagEvaluator lives in hooks/, SchemaRenderer exports its plain half (objectui#10290) A hook declared in SchemaRenderer.tsx puts that file under the React Compiler lint, which then reports three pre-existing render-time performance.now() / Date.now() debug probes as react-hooks/purity errors (measured: 0 errors without the hook, 3 with it). The hook moves to hooks/useConfigBagEvaluator.ts; SchemaRenderer.tsx exports the plain evaluateConfigBagInScope() it calls, which applies the memo's own per-key rule and scope. The scope builder's predicate scope is typed unknown instead of any. The pin test's schema cast names BaseSchema. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...ion-container-member-params-10290.test.tsx | 3 +- packages/react/src/SchemaRenderer.tsx | 65 +++++++++---------- packages/react/src/hooks/index.ts | 3 + .../react/src/hooks/useConfigBagEvaluator.ts | 42 ++++++++++++ 4 files changed, 77 insertions(+), 36 deletions(-) create mode 100644 packages/react/src/hooks/useConfigBagEvaluator.ts diff --git a/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx index df7f621d42..57c3df4659 100644 --- a/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx +++ b/packages/components/src/renderers/action/__tests__/action-container-member-params-10290.test.tsx @@ -32,6 +32,7 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea import '@testing-library/jest-dom'; import React from 'react'; import type { ActionContext, ActionDef, ActionResult } from '@object-ui/core'; +import type { BaseSchema } from '@object-ui/types'; import { ActionProvider, RecordContextProvider, SchemaRenderer } from '@object-ui/react'; // Module-scope side-effect imports: `action:bar` resolves its members and its // overflow menu through the ComponentRegistry at render time, and the light @@ -81,7 +82,7 @@ function renderOnRecordPage(schema: Record) { return render( - + , ); diff --git a/packages/react/src/SchemaRenderer.tsx b/packages/react/src/SchemaRenderer.tsx index cf6e2ceb01..628a223faa 100644 --- a/packages/react/src/SchemaRenderer.tsx +++ b/packages/react/src/SchemaRenderer.tsx @@ -543,16 +543,16 @@ const isDataObjectValue = (key: string, value: unknown): boolean => * `page`. Why each root is (and `data` is not) bound is stated where the memo * builds its evaluator from this. * - * One function, so the memo and {@link useConfigBagEvaluator} cannot build two - * different scopes (objectui#10290). + * One function, so the memo and {@link evaluateConfigBagInScope} cannot build + * two different scopes (objectui#10290). */ const configEvaluationScope = ( - predicateScope: Record, + predicateScope: Record, boundRecord: unknown, pageVariables: unknown, -): Record => ({ +): Record => ({ ...predicateScope, - current_user: (predicateScope as any)?.user, + current_user: predicateScope?.user, ...(boundRecord && typeof boundRecord === 'object' && !Array.isArray(boundRecord) ? { record: boundRecord } : null), @@ -594,37 +594,32 @@ const evaluateConfigBagWith = ( }; /** - * The `properties` evaluation of the {@link SchemaRenderer} memo, for a node - * that is rendered WITHOUT `SchemaRenderer` (objectui#10290). - * - * The action containers (`action:bar`, `action:group`, `action:menu` in - * `@object-ui/components`) draw their member actions themselves, so a member's - * `properties` never passes through the memo. Its static execution values ride - * `properties.params` (objectui#10289), and those values are templates - * evaluated where `properties` are (objectui#7867). This hook is that - * evaluation: the same per-key rule, the same `ExpressionEvaluator`, and the - * same scope, read from the same contexts the memo reads. It is not a second - * template engine. - * - * The returned function takes a config bag and returns a new, evaluated bag. A - * value that is not a config bag is returned unchanged. Call it once per - * authored bag: the result is already evaluated, and evaluating it again would - * interpolate text that came out of the row. - * - * A fresh function each render. Nothing may depend on its identity - * (AGENTS.md #10). + * The `properties` evaluation of the {@link SchemaRenderer} memo, for a config + * bag that does not pass through `SchemaRenderer` (objectui#10290): the same + * per-key rule, the same `ExpressionEvaluator`, and the same scope, built from + * the same three inputs. It is not a second template engine. + * + * The plain half of `useConfigBagEvaluator()` (`hooks/useConfigBagEvaluator`), + * which reads those inputs from the contexts the memo reads them from. Call + * the hook; this function exists because the rule it applies is this module's. + * (The hook is not declared in this module: a hook here puts this file under + * the React Compiler lint, which then reports the render-time + * `performance.now()` / `Date.now()` debug probes below.) + * + * A value that is not a config bag is returned unchanged; a bag comes back as a + * new, evaluated object and the authored one is never mutated. */ -export function useConfigBagEvaluator(): (bag: unknown) => unknown { - const predicateScope = usePredicateScope(); - const boundRecord = useRecordContext()?.data; - const { variables: pageVariables } = usePageVariables(); - return (bag: unknown): unknown => { - if (!isConfigBag(bag)) return bag; - const evaluator = new ExpressionEvaluator( - configEvaluationScope(predicateScope, boundRecord, pageVariables), - ); - return evaluateConfigBagWith(evaluator, bag as Record); - }; +export function evaluateConfigBagInScope( + bag: unknown, + predicateScope: Record, + boundRecord: unknown, + pageVariables: unknown, +): unknown { + if (!isConfigBag(bag)) return bag; + const evaluator = new ExpressionEvaluator( + configEvaluationScope(predicateScope, boundRecord, pageVariables), + ); + return evaluateConfigBagWith(evaluator, bag as Record); } /** diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 93b35397da..9bac804df8 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -43,6 +43,9 @@ export * from './useActionEngine.js'; // authored strings an action carries (label / confirmText / successMessage). export * from './useActionTextLocalizer.js'; export * from './useCapabilityGate.js'; +// The `SchemaRenderer` memo's `properties` evaluation, for an action container's +// members, which are drawn without `SchemaRenderer` (objectui#10290). +export * from './useConfigBagEvaluator.js'; // The analytics label net's React glue, consumed by BOTH plugin-dashboard's // `DatasetWidget` and plugin-report's dataset block (objectui#4389). It lives // here rather than in `@object-ui/core` because it reads `SchemaRendererContext` diff --git a/packages/react/src/hooks/useConfigBagEvaluator.ts b/packages/react/src/hooks/useConfigBagEvaluator.ts new file mode 100644 index 0000000000..8b01c52958 --- /dev/null +++ b/packages/react/src/hooks/useConfigBagEvaluator.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +import { useRecordContext } from '../context/RecordContext.js'; +import { evaluateConfigBagInScope } from '../SchemaRenderer.js'; +import { usePredicateScope } from './useExpression.js'; +import { usePageVariables } from './usePageVariables.js'; + +/** + * The `properties` evaluation of the `SchemaRenderer` memo, for a node that is + * rendered WITHOUT `SchemaRenderer` (objectui#10290). + * + * The action containers (`action:bar`, `action:group`, `action:menu` in + * `@object-ui/components`) draw their member actions themselves, so a member's + * `properties` never passes through the memo. Its static execution values ride + * `properties.params` (objectui#10289), and those values are templates + * evaluated where `properties` are (objectui#7867). This hook is that + * evaluation: the memo's per-key rule, its `ExpressionEvaluator`, and its + * scope (the host's ambient roots, `current_user`, the page's bound row as + * `record`, and page variables as `page`), read from the same contexts the memo + * reads. It is not a second template engine. See `evaluateConfigBagInScope`. + * + * The returned function takes a config bag and returns a new, evaluated bag. A + * value that is not a config bag is returned unchanged. Call it once per + * authored bag: the result is already evaluated, and evaluating it again would + * interpolate text that came out of the row. + * + * A fresh function each render. Nothing may depend on its identity + * (AGENTS.md #10). + */ +export function useConfigBagEvaluator(): (bag: unknown) => unknown { + const predicateScope = usePredicateScope(); + const boundRecord = useRecordContext()?.data; + const { variables: pageVariables } = usePageVariables(); + return (bag: unknown): unknown => + evaluateConfigBagInScope(bag, predicateScope, boundRecord, pageVariables); +} From e8b96c0161780a3bbf6f0d4f3cbfd51f90de6400 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:57:45 +0000 Subject: [PATCH 4/5] docs(changeset): name evaluateConfigBagInScope beside useConfigBagEvaluator (objectui#10290) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10290-container-member-params-evaluated.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/10290-container-member-params-evaluated.md b/.changeset/10290-container-member-params-evaluated.md index 84787ddbe3..312884def3 100644 --- a/.changeset/10290-container-member-params-evaluated.md +++ b/.changeset/10290-container-member-params-evaluated.md @@ -24,4 +24,6 @@ not read as values (objectui#10289). `@object-ui/react` exports `useConfigBagEvaluator()` for this. It returns the `SchemaRenderer` memo's own `properties` evaluation (the same per-key rule and the same `record` / `current_user` / `page` scope) for a node rendered without -`SchemaRenderer`. It is not a second template engine. +`SchemaRenderer`. It is not a second template engine. Its plain half, +`evaluateConfigBagInScope()`, is exported beside `SchemaRenderer`, which applies +the same rule through it. From 3931a9dba4d23bc15ee75192c1743d9e34695efc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:41:42 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(changeset):=20declare=20react=20minor?= =?UTF-8?q?=20and=20correct=20wording=20(Clause-=E2=91=A1=20widening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review on e8b96c0161 (FAIL, text/semver only) found that two new `@object-ui/react` exports (`useConfigBagEvaluator`, `evaluateConfigBagInScope`) widen the published surface, so the bump must be `minor`, not `patch`. The changeset's paragraphs 2-4 are replaced with the review's corrected text (fixes the self-contradictory api-window sentence, the "returns the memo's own evaluation" overstatement, and the false "SchemaRenderer applies the same rule through it" claim), and a new paragraph declares the minor as Clause-② widening. `static-params.ts` gets two comment-only fixes: the group/menu parenthetical now says the `properties.params` channel is read off the authored object (no declared `properties` key on `UIActionSchema`), not off a declared key; and Zone 1's docblock now says a container member is evaluated by its container with the same rule and scope, since it never passes through `SchemaRenderer`. No code line changed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- ...10290-container-member-params-evaluated.md | 32 ++++++++++++------- .../src/renderers/action/static-params.ts | 15 ++++++--- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/.changeset/10290-container-member-params-evaluated.md b/.changeset/10290-container-member-params-evaluated.md index 312884def3..e8d1f97fc6 100644 --- a/.changeset/10290-container-member-params-evaluated.md +++ b/.changeset/10290-container-member-params-evaluated.md @@ -1,6 +1,6 @@ --- '@object-ui/components': patch -'@object-ui/react': patch +'@object-ui/react': minor --- fix(components): an action container's members evaluate `properties.params` the way a top-level `action:button` does @@ -16,14 +16,24 @@ at all. Each container now evaluates its members' `properties` with the evaluator and scope `SchemaRenderer` uses: `action:bar` where it composes an inline member, and `action:group` / `action:menu` when they run an item. A container member's -static values therefore reach the handler exactly as a top-level -`action:button`'s do. An item that carries an `ActionParam[]` input list -forwards both, as `action:button` does. A node-level `params` object is still -not read as values (objectui#10289). +static values therefore reach the handler as a top-level `action:button`'s do, +with one difference: the development-build unevaluated-expression diagnostic +runs in the `SchemaRenderer` memo, so it does not report an unresolvable +template on a container member. An item that carries an `ActionParam[]` input +list forwards both, as `action:button` does. A node-level `params` object is +still not read as values (objectui#10289). The one exception is unchanged from +objectui#10462: an `action:group` / `action:menu` item of `type: 'api'` still +forwards it as the request payload (objectstack#5777 window), unless the item +also carries a `properties.params` bag, which now takes precedence. -`@object-ui/react` exports `useConfigBagEvaluator()` for this. It returns the -`SchemaRenderer` memo's own `properties` evaluation (the same per-key rule and -the same `record` / `current_user` / `page` scope) for a node rendered without -`SchemaRenderer`. It is not a second template engine. Its plain half, -`evaluateConfigBagInScope()`, is exported beside `SchemaRenderer`, which applies -the same rule through it. +`@object-ui/react` exports `useConfigBagEvaluator()` for this. It returns a +function that applies the `SchemaRenderer` memo's `properties` evaluation (the +same per-key rule and the same `record` / `current_user` / `page` scope) to a +config bag rendered without `SchemaRenderer`. It is not a second template +engine. Its plain half, `evaluateConfigBagInScope()`, is exported beside +`SchemaRenderer`. Both are built from the module-scope scope builder and +per-key rule that the memo itself calls. + +Declared as a minor (Clause-②, widening): `@object-ui/react` gains two public +exports, `useConfigBagEvaluator` and `evaluateConfigBagInScope`. Nothing is +removed or renamed. diff --git a/packages/components/src/renderers/action/static-params.ts b/packages/components/src/renderers/action/static-params.ts index f3786f844b..7622f51d8d 100644 --- a/packages/components/src/renderers/action/static-params.ts +++ b/packages/components/src/renderers/action/static-params.ts @@ -19,8 +19,10 @@ * Static values a handler reads (`navigate_edit`'s `objectName` / `recordId`, * say) therefore ride the node's already-declared config bag, as * `properties.params`. `SchemaRenderer` template-evaluates every string leaf - * of that bag (objectui#10282), so `"recordId": "${record.id}"` resolves on a - * record page. + * of that bag; a container member, which never passes through + * `SchemaRenderer`, is evaluated by its container with the same rule and scope + * (`useConfigBagEvaluator`, objectui#10290) (objectui#10282), so `"recordId": + * "${record.id}"` resolves on a record page. * * The maintainer's ruling on objectui#10289 (letter A, 2026-09-25): `params` * never carries two shapes, and no new value-bag key is declared. So an OBJECT @@ -150,9 +152,12 @@ export function readMemberStaticParamValues( * inline `action`, `action:group` / `action:menu` items and `page:header`'s * actions. `params` is an entry's `ActionParam[]` input list and nothing else. * (An `action:group` / `action:menu` item is also a container member, so its - * static values ride `properties.params`, read by - * {@link readMemberStaticParamValues} — objectui#10290. `element:button`'s - * inline `action` and `page:header`'s actions do not read `properties.params`.) + * static values are read from `properties.params` and evaluated by the + * container through {@link readMemberStaticParamValues} (objectui#10290). + * `UIActionSchema` declares no `properties`, so on a container member this bag + * is read off the authored object, not off a declared key. `element:button`'s + * inline `action` and `page:header`'s actions do not read + * `properties.params`.) * * One type is still different: `api`. The objectstack#5777 window keeps the * runner reading an object `params` as the request payload (with its own