Skip to content

Commit f5178a2

Browse files
fix(components): spec action entries stop forwarding an object params as values on non-api types (objectui#10462) (#10579)
Fixes #10462 Clause-②: no ## What this does This finishes ruling A on objectui#10289 for the spec action-entry surfaces. Under that ruling, an action's `params` is only the `ActionParam[]` input list. Triage (comment `5827659739`) moves the non-`api` types now, and the `api` type keeps the objectstack#5777 window. The four surfaces now route `params` the way PR objectui#10460 routes it on `action:button`: | surface | array `params` | object `params`, non-`api` type | object `params`, `type: 'api'` | |:--|:--|:--|:--| | `element:button` inline `action` | `actionParams` (as before) | **not forwarded** + dev warning | unchanged | | `action:group` items | `actionParams` (was `params`) | **not forwarded** + dev warning | unchanged | | `action:menu` items | `actionParams` (was `params`) | **not forwarded** + dev warning | unchanged | | `page:header` `dispatchHeaderAction`, with a record | `actionParams` (as before) | **not merged**; the `_rowRecord` stash stays | merged with the stash, as before | | `page:header`, no record context | dispatched as authored (as before) | **dropped** + dev warning | dispatched as authored, as before | - **One shared helper.** `readActionEntryParamValues` sits in `static-params.ts`, next to PR objectui#10460's `readStaticParamValues`, and all four sites call it. It uses the same warn-once memo and the same production gate. It is a separate function because a spec action entry has no `properties.params` bag, so the node helper's warning would send the author to a key this surface does not have. For `api` it returns the object unchanged. The runner still reads that object as the payload and prints its own objectstack#5777 warning, which names `bodyExtra`. - **The `api` decision uses the dispatched type.** That is `action.actionType || action.type` on `element:button` (the value it forwards as `type`), `action.type` on the group and menu, and `type || actionType` on `page:header`, which is the runner's own order. - **`page:header`'s stash is kept.** When there is a record, `params` is `{ _rowRecord }` for a non-`api` action. Only the authored object is left out. When there is no record context, the entry is still dispatched as authored, except that a non-`api` object `params` is removed. Without that, the no-record path would still pass the values through. - **Why the array now goes to `actionParams` on the group and menu:** it mirrors `action:button`. The runner reads the collection list from `actionParams` or an array `params`, whichever is present, so param collection behaves as before. The forwarded-params pin in `action-forward-parity.test.tsx` stays green. - `MetadataTypeActions.tsx` (app-shell) was measured and **left unchanged**. It rejects any `type` other than `api` with a toast before it reads `params`, and it does not go through the action runner (it builds and sends its own request). So it never forwards an object `params` on a non-`api` type. Its object read is the `api` arm, which this card does not touch. ## Reproduction (base `8740e86ce`, before the change) The new pin file was committed first and run on the unchanged base: **10 failed / 9 passed (19)**. All five surface variants failed the non-`api` pin with `expected 'account' to be undefined`: the `navigate_edit` handler received the authored `objectName` / `recordId` as `ActionDef.params`. The group and menu array pins failed with `expected undefined to deeply equal [ { name: 'reason', … } ]`, because the list reached the runner under `params` and not `actionParams`. The `page:header` stash pin failed with `expected { objectName: 'account', …(2) } to deeply equal { _rowRecord: … }`. All five `api` controls passed. ## Tests New pin file `packages/components/src/renderers/action/__tests__/action-entry-object-params-10462.test.tsx` (19 tests). It runs over 5 surface variants (`element:button`, `action:group`, `action:menu`, `page:header` with and without a record). Each variant gets three pins: non-`api` object not forwarded plus one warning, array as `actionParams`, and the `api` control. The file adds two `page:header` stash pins (non-`api` drop, and the `api` merge control) and two warning pins (once per action across clicks; silent in production, values still dropped). After the change: **19 passed (19)**. Union at the final head `1d8b74e83` (after merging `origin/main`), from the repo root: - `pnpm exec vitest run --maxWorkers=2 packages/components/src/renderers/` plus `page-header-actions`, `element-button-action`, `elementButtonActionMembers-8071`, `action-group`, `action-bodyShape-forward` and `scripts/__tests__/check-action-forward-parity.test.ts`: **132 files, 1323 passed, 10 skipped**. - Full `packages/components/` on the pre-merge head `44b1d0c9a`: **302 files passed, 1 skipped; 2989 tests passed, 17 skipped**. The merge touched none of this change's files. - `@object-ui/components` `type-check` (`tsc --noEmit && tsc -p tsconfig.test.json`; the new test file is in the second program, count 1 from `--listFilesOnly`): green at `1d8b74e83`, after building the dependency closure. ## Reverse verification (ablation) Both legs were run through `ablation-replace.mjs` after the commit. The anchor was proven to land (1 to 0, blob changed), and the restore was proven on disk (blob equal to HEAD, `git diff HEAD` empty). The tests import the renderers by relative path, so the mutated source is what runs. The direction was predicted before each run. 1. `action:group`'s object branch reverted to a raw `params: action.params` forward: **2 red** (the group non-`api` pin and the warn-once pin), 17 green. Predicted: the array and `api` legs stay green. 2. `page:header`'s record branch reverted to merging `rawParams` into the stash: **2 red** (the header non-`api` pin and the stash pin), 17 green. The no-record variant stays green, as predicted. ## Gates run locally (at `1d8b74e83`) Green: `check:action-forward-parity` (5 surfaces, 0 known gaps, every payload excess-property CHECKED), `check:handler-key-reads`, `check:control-bytes`, `check:new-line-citations` (0 new), `check:changeset-claims`, `check:unreferenced-sources`, `changeset:check`, `check-changeset-presence` (1 changeset for 6 published source files). Before the merge the following were also green: `check:pending-changeset-literals`, `check:test-path-roots`, `check:action-ref-convention`, `check:spec-symbols`, `check:component-surface-parity`. - ESLint on the 6 changed ts/tsx files (`--format json`): 6 files, 0 errors. The new lines add two `no-explicit-any` warnings, one each in group and menu, for `action.params as any` on the `actionParams` branch. This is the same cast `action:button` uses: `UIActionSchema`'s `ActionParam[]` does not assign to the runner's `ActionParamDef[]` (TS2322 without it, measured). - `check-governed-queue-guard --test` on the 7 changed paths: NOT GOVERNED. - NOT MEASURED locally: repo-wide `pnpm lint` and the full `pnpm test`. CI runs them. ## Acceptance notes - `@object-ui/app-shell` is not touched, so there is no app-shell changeset. - Only metadata that skipped validation is affected: the spec already refuses an object `params` at the authoring door. PR objectui#10460's census found no in-repo JSON authoring the object form on an action node. This PR did not re-take that census for these entry surfaces. - The `api` arm on these surfaces is still retired together with the objectstack#5777 removal at 18, as the card's direction says. Nothing here moves it. Attribution: this change was written in session `https://claude.ai/code/session_01D486C1axmXnrkJMNUfz2eb` (seat `domain:ui#3`). --- _Generated by [Claude Code](https://claude.ai/code/session_01D486C1axmXnrkJMNUfz2eb)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent caf0ed0 commit f5178a2

7 files changed

Lines changed: 317 additions & 7 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/components': patch
3+
---
4+
5+
fix(components): spec action entries stop forwarding an object `params` as values on non-`api` types
6+
7+
This finishes ruling A on objectui#10289 for the spec action-entry surfaces: an
8+
action's `params` is only the `ActionParam[]` list of inputs to collect from the
9+
user. `action:button` / `action:icon` already follow it. Now `element:button`'s
10+
inline `action`, `action:group` items, `action:menu` items and `page:header`'s
11+
header actions follow it too (objectui#10462).
12+
13+
- An ARRAY `params` is forwarded to the runner as `actionParams`, the input list.
14+
`action:group` and `action:menu` used to forward it under `params`. The runner
15+
read both spellings the same way, so param collection behaves as before.
16+
- An OBJECT `params` on any action type except `api` is no longer forwarded as
17+
`ActionDef.params` values. A development build logs one warning per action,
18+
and production is silent. `page:header` still stashes the current record
19+
under `params._rowRecord`; only the authored object is left out.
20+
- An OBJECT `params` on a `type: 'api'` action is unchanged. The objectstack#5777
21+
window keeps the runner reading it as the request payload, with its own
22+
warning naming `bodyExtra`, until 18.
23+
24+
The spec already refuses an object `params` at the authoring door, so only
25+
metadata that skipped validation is affected.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10462: the spec ACTION-ENTRY surfaces follow ruling A on
11+
* objectui#10289 (`params` is only the `ActionParam[]` input list) for every
12+
* action type except `api`.
13+
*
14+
* The four surfaces are `element:button`'s inline `action`, `action:group`
15+
* items, `action:menu` items and `page:header`'s `dispatchHeaderAction`. They
16+
* hand the runner a spec action entry, not an SDUI node, so there is no
17+
* `properties.params` bag to read values from. Per surface:
18+
*
19+
* - a non-`api` action with an OBJECT `params` does not reach the runner
20+
* with those values, and a development build says so once;
21+
* - an ARRAY `params` still reaches the runner as `actionParams`;
22+
* - a `type: 'api'` action with an OBJECT `params` is unchanged. That is the
23+
* objectstack#5777 window: the runner still reads it as the request payload
24+
* until 18. It is the control leg of every surface below.
25+
*
26+
* The value asserted is the `ActionDef` a registered handler receives, which
27+
* is what the runner dispatched.
28+
*/
29+
30+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
31+
import { render, fireEvent, screen, waitFor } from '@testing-library/react';
32+
import React from 'react';
33+
import { ComponentRegistry } from '@object-ui/core';
34+
import type { ActionContext, ActionDef, ActionResult, ParamCollectionHandler } from '@object-ui/core';
35+
import { ActionProvider, RecordContextProvider } from '@object-ui/react';
36+
// Module-scope side-effect imports: the renderers register themselves on
37+
// import, and the light `dom` project does not load the components graph.
38+
// Module scope, not a `beforeAll`, per AGENTS.md 测试纪律.
39+
import '../action-group';
40+
import '../action-menu';
41+
import '../../basic/elements';
42+
import '../../layout/containers';
43+
import { resetStaticParamsWarnings } from '../static-params';
44+
45+
type Handler = Mock<(action: ActionDef, ctx: ActionContext) => Promise<ActionResult>>;
46+
47+
let navigateEdit: Handler;
48+
let api: Handler;
49+
let onParamCollection: Mock<ParamCollectionHandler>;
50+
let warn: ReturnType<typeof vi.spyOn>;
51+
52+
beforeEach(() => {
53+
navigateEdit = vi.fn(async () => ({ success: true }));
54+
api = vi.fn(async () => ({ success: true }));
55+
// Cancel collection: the array leg is about what the runner is HANDED.
56+
onParamCollection = vi.fn(async () => null);
57+
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
58+
resetStaticParamsWarnings();
59+
});
60+
61+
afterEach(() => {
62+
warn.mockRestore();
63+
});
64+
65+
const VALUES = { objectName: 'account', recordId: 'rec_7' };
66+
const INPUTS = [{ name: 'reason', type: 'text', label: 'Reason' }];
67+
const RECORD = { id: 'rec-1', status: 'open' };
68+
69+
/** This change's development warnings, as strings. */
70+
const entryWarnings = () =>
71+
warn.mock.calls.map((c: unknown[]) => String(c[0])).filter((m: string) => m.includes('objectui#10462'));
72+
73+
function Registered({ type, schema }: { type: string; schema: Record<string, unknown> }) {
74+
const C = ComponentRegistry.get(type);
75+
if (!C) throw new Error(`${type} is not registered`);
76+
// eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered renderer (stable reference), not a component created during render
77+
return <C schema={{ ...schema, type }} />;
78+
}
79+
80+
type Surface = 'element:button' | 'action:group' | 'action:menu' | 'page:header' | 'page:header (no record)';
81+
82+
/** The node that carries one action entry on each surface. */
83+
function nodeFor(surface: Surface, entry: Record<string, unknown>): { type: string; schema: Record<string, unknown> } {
84+
switch (surface) {
85+
case 'element:button':
86+
return {
87+
type: 'element:button',
88+
schema: { properties: { label: String(entry.label), action: entry } },
89+
};
90+
case 'action:group':
91+
return { type: 'action:group', schema: { display: 'inline', actions: [entry] } };
92+
case 'action:menu':
93+
// `autoTrigger` runs `handleExecute`, the function a menu-item click
94+
// calls, without opening the Radix dropdown (see action-forward-parity).
95+
return { type: 'action:menu', schema: { actions: [{ ...entry, autoTrigger: true }] } };
96+
default:
97+
return {
98+
type: 'page:header',
99+
schema: { title: 'Plan', actions: [{ ...entry, locations: ['record_header'] }] },
100+
};
101+
}
102+
}
103+
104+
/** Mount one action entry on a surface and run it. */
105+
function mount(surface: Surface, entry: Record<string, unknown>) {
106+
const { type, schema } = nodeFor(surface, entry);
107+
const node = <Registered type={type} schema={schema} />;
108+
render(
109+
<ActionProvider handlers={{ navigate_edit: navigateEdit, api }} onParamCollection={onParamCollection}>
110+
{surface === 'page:header' ? (
111+
<RecordContextProvider
112+
objectName="os_plan"
113+
recordId={RECORD.id}
114+
data={RECORD}
115+
objectSchema={{ name: 'os_plan', label: 'Plan' }}
116+
>
117+
{node}
118+
</RecordContextProvider>
119+
) : (
120+
node
121+
)}
122+
</ActionProvider>,
123+
);
124+
if (surface !== 'action:menu') {
125+
fireEvent.click(screen.getByRole('button', { name: new RegExp(String(entry.label)) }));
126+
}
127+
}
128+
129+
async function dispatchedTo(handler: Handler): Promise<ActionDef> {
130+
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
131+
return handler.mock.calls[0][0];
132+
}
133+
134+
/** `element:button` names its executor `actionType`; the spec entries say `type`. */
135+
const typed = (surface: Surface, type: string) =>
136+
surface === 'element:button' ? { actionType: type } : { type };
137+
138+
const SURFACES: Surface[] = ['element:button', 'action:group', 'action:menu', 'page:header', 'page:header (no record)'];
139+
140+
describe.each(SURFACES)('%s: an object `params` is values only on `type: "api"` (objectui#10462)', (surface) => {
141+
const where = surface.startsWith('page:header') ? 'page:header' : surface;
142+
143+
it('does NOT forward an object `params` as values on a non-api action, and says so once', async () => {
144+
mount(surface, { name: 'edit_it', label: 'Edit It', ...typed(surface, 'navigate_edit'), params: VALUES });
145+
const def = await dispatchedTo(navigateEdit);
146+
const params = def.params as Record<string, unknown> | undefined;
147+
expect(params?.objectName).toBeUndefined();
148+
expect(params?.recordId).toBeUndefined();
149+
expect(def.actionParams).toBeUndefined();
150+
const warnings = entryWarnings();
151+
expect(warnings).toHaveLength(1);
152+
expect(warnings[0]).toContain(`[${where}]`);
153+
expect(warnings[0]).toContain('"edit_it"');
154+
});
155+
156+
it('keeps an `ActionParam[]` input list, as `actionParams`', async () => {
157+
mount(surface, { name: 'ask_it', label: 'Ask It', ...typed(surface, 'navigate_edit'), params: INPUTS });
158+
await waitFor(() => expect(onParamCollection).toHaveBeenCalledTimes(1));
159+
expect(onParamCollection.mock.calls[0][0]).toEqual(INPUTS);
160+
// Outside a record context `page:header` dispatches the entry as authored,
161+
// and the runner reads the array `params` itself; everywhere else the list
162+
// is forwarded under `actionParams`, as `action:button` does.
163+
if (surface !== 'page:header (no record)') {
164+
expect((onParamCollection.mock.calls[0][1] as ActionDef).actionParams).toEqual(INPUTS);
165+
}
166+
expect(entryWarnings()).toEqual([]);
167+
});
168+
169+
it('control: a `type: "api"` object `params` still arrives as the payload (objectstack#5777 window)', async () => {
170+
mount(surface, { name: 'post_it', label: 'Post It', ...typed(surface, 'api'), target: '/api/v1/ping', params: VALUES });
171+
const def = await dispatchedTo(api);
172+
expect(def.params).toMatchObject(VALUES);
173+
expect(entryWarnings()).toEqual([]);
174+
});
175+
});
176+
177+
describe('page:header keeps its record stash (objectui#10462)', () => {
178+
it('stashes `_rowRecord` on a non-api action whose object `params` it drops', async () => {
179+
mount('page:header', { name: 'edit_stash', label: 'Edit Stash', type: 'navigate_edit', params: VALUES });
180+
const def = await dispatchedTo(navigateEdit);
181+
expect(def.params).toEqual({ _rowRecord: RECORD });
182+
});
183+
184+
it('control: merges the stash into an api action`s object `params`, as before', async () => {
185+
mount('page:header', { name: 'post_stash', label: 'Post Stash', type: 'api', target: '/api/v1/ping', params: VALUES });
186+
const def = await dispatchedTo(api);
187+
expect(def.params).toEqual({ ...VALUES, _rowRecord: RECORD });
188+
});
189+
});
190+
191+
describe('the warning is development-only and once per action (objectui#10462)', () => {
192+
it('warns once across repeated clicks', async () => {
193+
mount('action:group', { name: 'edit_twice', label: 'Edit Twice', type: 'navigate_edit', params: VALUES });
194+
await dispatchedTo(navigateEdit);
195+
fireEvent.click(screen.getByRole('button', { name: /Edit Twice/ }));
196+
await waitFor(() => expect(navigateEdit).toHaveBeenCalledTimes(2));
197+
expect(entryWarnings()).toHaveLength(1);
198+
});
199+
200+
it('is silent in production and still drops the values', async () => {
201+
vi.stubEnv('NODE_ENV', 'production');
202+
try {
203+
mount('element:button', { name: 'edit_prod', label: 'Edit Prod', actionType: 'navigate_edit', params: VALUES });
204+
const def = await dispatchedTo(navigateEdit);
205+
expect(def.params).toBeUndefined();
206+
expect(entryWarnings()).toEqual([]);
207+
} finally {
208+
vi.unstubAllEnvs();
209+
}
210+
});
211+
});

‎packages/components/src/renderers/action/action-group.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import React, { forwardRef, useCallback, useState } from 'react';
2020
import { ComponentRegistry } from '@object-ui/core';
21+
import type { ActionDef } from '@object-ui/core';
2122
import type { UIActionSchema, ActionLocation } from '@object-ui/types';
2223
import { actionRendersAt } from '@object-ui/types';
2324
import { useAction } from '@object-ui/react';
@@ -34,6 +35,7 @@ import { cn } from '../../lib/utils';
3435
import { Loader2, ChevronDown } from 'lucide-react';
3536
import { resolveIcon } from './resolve-icon';
3637
import { hasDeclaredVisibilityGate } from './visibility-gate';
38+
import { readActionEntryParamValues } from './static-params';
3739

3840
export interface ActionGroupSchema {
3941
type: 'action:group';
@@ -255,6 +257,13 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
255257
await action.onClick();
256258
return;
257259
}
260+
// `params` is the `ActionParam[]` input list (ruling A on objectui#10289):
261+
// an array is forwarded as `actionParams`, as `action:button` does. An
262+
// object is forwarded as values only for `type: 'api'`, the objectstack#5777
263+
// payload window; any other type drops it (objectui#10462).
264+
const paramsPayload: ActionDef = Array.isArray(action.params)
265+
? { actionParams: action.params as any }
266+
: { params: readActionEntryParamValues(action, action.type, 'action:group') };
258267
await execute({
259268
type: action.type,
260269
name: action.name,
@@ -267,7 +276,7 @@ const ActionGroupRenderer = forwardRef<HTMLDivElement, { schema: ActionGroupSche
267276
openIn: (action as any).openIn,
268277
endpoint: action.endpoint,
269278
method: action.method,
270-
params: action.params as Record<string, any> | undefined,
279+
...paramsPayload,
271280
// See action-button.tsx — the `type: 'api'` payload key (objectstack#6837).
272281
bodyExtra: action.bodyExtra,
273282
// See action-button.tsx — the body-WRAPPING key (objectstack#6938).

‎packages/components/src/renderers/action/action-menu.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import React, { forwardRef, useCallback, useMemo, useState } from 'react';
1717
import { ComponentRegistry } from '@object-ui/core';
18+
import type { ActionDef } from '@object-ui/core';
1819
import type { UIActionSchema } from '@object-ui/types';
1920
import { useAction } from '@object-ui/react';
2021
import { useCondition, toPredicateInput, usePredicateRecordContext } from '@object-ui/react';
@@ -32,6 +33,7 @@ import { Loader2, MoreHorizontal } from 'lucide-react';
3233
import { resolveIcon } from './resolve-icon';
3334
import { hasDeclaredVisibilityGate } from './visibility-gate';
3435
import { useAutoTriggerOnce } from './auto-trigger';
36+
import { readActionEntryParamValues } from './static-params';
3537

3638
function useMoreActionsLabel(): string {
3739
// useObjectTranslation is provider-safe (never throws); no try/catch, which
@@ -234,6 +236,13 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
234236
await action.onClick();
235237
return;
236238
}
239+
// `params` is the `ActionParam[]` input list (ruling A on objectui#10289):
240+
// an array is forwarded as `actionParams`, as `action:button` does. An
241+
// object is forwarded as values only for `type: 'api'`, the objectstack#5777
242+
// payload window; any other type drops it (objectui#10462).
243+
const paramsPayload: ActionDef = Array.isArray(action.params)
244+
? { actionParams: action.params as any }
245+
: { params: readActionEntryParamValues(action, action.type, 'action:menu') };
237246
await execute({
238247
type: action.type,
239248
name: action.name,
@@ -255,7 +264,7 @@ const ActionMenuRenderer = forwardRef<HTMLButtonElement, { schema: ActionMenuSch
255264
openIn: (action as any).openIn,
256265
endpoint: action.endpoint,
257266
method: action.method,
258-
params: action.params as Record<string, any> | undefined,
267+
...paramsPayload,
259268
// See action-button.tsx — the `type: 'api'` payload key (objectstack#6837).
260269
bodyExtra: action.bodyExtra,
261270
// See action-button.tsx — the body-WRAPPING key (objectstack#6938).

‎packages/components/src/renderers/action/static-params.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,46 @@ export function readStaticParamValues(
9393
}
9494
return values;
9595
}
96+
97+
/**
98+
* The same ruling on a spec ACTION ENTRY (objectui#10462): `element:button`'s
99+
* inline `action`, `action:group` / `action:menu` items and `page:header`'s
100+
* actions. An entry is not an SDUI node, so it has no `properties.params` bag;
101+
* `params` is its `ActionParam[]` input list and nothing else.
102+
*
103+
* One type is still different: `api`. The objectstack#5777 window keeps the
104+
* runner reading an object `params` as the request payload (with its own
105+
* development warning, naming `bodyExtra`) until 18, so for `api` the object is
106+
* returned unchanged. For every other type it is not returned, and a
107+
* development build says so once per action.
108+
*
109+
* `type` is the executor the surface dispatches the entry as. Call this on the
110+
* non-array branch only; an array `params` is the input list, forwarded as
111+
* `actionParams`.
112+
*/
113+
export function readActionEntryParamValues(
114+
entry: StaticParamsSubject,
115+
type: unknown,
116+
where: string,
117+
): Record<string, unknown> | undefined {
118+
const params = entry.params;
119+
if (params == null || Array.isArray(params)) return undefined;
120+
// objectstack#5777: unchanged for `api` until the window closes at 18.
121+
if (type === 'api') return params as Record<string, unknown>;
122+
if (isConfigBag(params)) warnEntryObjectParams(entry, type, where);
123+
return undefined;
124+
}
125+
126+
function warnEntryObjectParams(entry: StaticParamsSubject, type: unknown, where: string): void {
127+
if (process.env.NODE_ENV === 'production') return;
128+
const name = String(entry.name ?? entry.label ?? '(unnamed)');
129+
const memo = `entry:${where}:${name}`;
130+
if (warned.has(memo)) return;
131+
warned.add(memo);
132+
console.warn(
133+
`[${where}] action "${name}" (type "${String(type)}") carries an OBJECT under \`params\`; ` +
134+
'it is not forwarded. `params` is only the `ActionParam[]` list of inputs to collect ' +
135+
'from the user. Only a `type: "api"` action still reads an object `params`, as its ' +
136+
'request payload, until 18 (use `bodyExtra`). See objectui#10462.',
137+
);
138+
}

‎packages/components/src/renderers/basic/elements.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { cn } from '../../lib/utils';
4040
import { LazyIcon } from '../../lib/lazy-icon';
4141
import { Button, Separator } from '../../ui';
4242
import { readProps } from './readProps';
43+
import { readActionEntryParamValues } from '../action/static-params';
4344

4445
// ---------------------------------------------------------------------------
4546
// Shared helpers
@@ -233,7 +234,9 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
233234
setRunning(true);
234235
try {
235236
// Mirror action:button's param routing: an array of {name,type,…} defs is
236-
// forwarded for in-dialog collection; a plain object is passed as values.
237+
// forwarded for in-dialog collection. A plain object is passed as values
238+
// only for `type: 'api'` (the objectstack#5777 payload window); on any
239+
// other type it is not forwarded (objectui#10462, ruling A on #10289).
237240
//
238241
// Annotated `ActionDef`, not bare: a spread SOURCE's own keys are not
239242
// excess-property checked THROUGH the spread (objectui#4281 probe Q —
@@ -242,7 +245,7 @@ function ElementButtonRenderer({ schema }: { schema: any }) {
242245
// does not cover these two branches; this annotation is what does.
243246
const paramsPayload: ActionDef = Array.isArray(action.params)
244247
? { actionParams: action.params }
245-
: { params: action.params };
248+
: { params: readActionEntryParamValues(action, action.actionType || action.type, 'element:button') };
246249
// ── Why there is no `as any` here (objectui#4321) ────────────────────
247250
//
248251
// This literal used to close with `as any`. An assertion asks only for

0 commit comments

Comments
 (0)