diff --git a/.changeset/6854-layout-renderer-retired-onclick.md b/.changeset/6854-layout-renderer-retired-onclick.md new file mode 100644 index 0000000000..94b2bee660 --- /dev/null +++ b/.changeset/6854-layout-renderer-retired-onclick.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': patch +'@object-ui/runner': patch +--- + +The standalone runner renders `AppAction.items` from its declared type only, which +makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854, +maintainer ruling of 2026-09-05, option B2). + +`AppAction.items` is `AppMenuItem[]`, and the zod mirror parses it with the legacy +eight-member `MenuItemSchema` — neither declares `onClick` or `shortcut`. +`LayoutRenderer` reached both through `as any`, past the type it was handed, and +that left three mutually exclusive signals about the same key: the TypeScript face +said `?: never`, the validator's refusal said "no renderer reads this key, so +nothing could ever run it", and a renderer read it. An agent or a reader could +believe any one of the three and be contradicted by the other two. + +**No published accept set moves and no exported symbol changes.** `AppAction.items` +is NOT re-typed (the alternative was measured and refused: it would have carried a +breaking migration for `path` / `href` / `badge` / `type` and the divider spelling, +for a capability with no measured consumer). The refusal message itself is unchanged +— it is shared by 22 other retired handler keys, and deleting the cast is what makes +its sentence true rather than restating it. + +- `@object-ui/runner`: `LayoutRenderer` no longer reads `onClick` or `shortcut` on a + `type: 'user'` action's `items`. The `onClick` branch was an empty body and could + never run a JSON value; the `shortcut` read rendered a `DropdownMenuShortcut` from + a key the mirror strips in silence, so no validated document could reach it. A + census of every JSON and TypeScript app document in this repository found zero + authors of either key (positive controls recorded on the issue). +- `@object-ui/types`: the rationale comments on `AppAction.onClick` and + `AppActionSchema.onClick` said "nothing reads `AppComponentSchema.actions[]`". + That was false — the runner renders both the `'button'` and the `'user'` arm. + Corrected to what was measured: `actions[]` is read, `onClick` is not. + +Whether `shortcut` should become authorable on `AppAction.items` is a separate +contract question and is filed on its own. diff --git a/packages/runner/src/LayoutRenderer.tsx b/packages/runner/src/LayoutRenderer.tsx index d039da3535..7e45da193e 100644 --- a/packages/runner/src/LayoutRenderer.tsx +++ b/packages/runner/src/LayoutRenderer.tsx @@ -25,7 +25,6 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuShortcut, Avatar, AvatarImage, AvatarFallback, @@ -298,20 +297,28 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou + {/* + * Renders `AppAction.items` from its DECLARED type and nothing else + * (objectui#6854, maintainer ruling of 2026-09-05, option B2). + * + * `items` is `AppMenuItem[]` (`@object-ui/types` `app.ts`), and the zod + * mirror parses it with the legacy eight-member `MenuItemSchema`. + * Neither declares `onClick` or `shortcut`; this map used to reach both + * through `as any`, i.e. past the type it was handed. The `onClick` read + * is also what made the retirement refusal's own sentence — "no renderer + * reads this key, so nothing could ever run it" — false. `type` and + * `label` ARE declared on `AppMenuItem` and stay. + * + * Whether `shortcut` should become authorable on `AppAction.items` is a + * separate contract question; do not re-add either read to answer it. + */} {userAction.items?.map((item, idx) => { if (item.type === 'separator') { return ; } return ( - { - if ((item as any).onClick) { - // Handle click logic - } - }}> + {item.label} - {(item as any).shortcut && ( - {(item as any).shortcut} - )} ); })} diff --git a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx new file mode 100644 index 0000000000..409cb37853 --- /dev/null +++ b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx @@ -0,0 +1,122 @@ +/** + * 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. + */ + +/** + * `LayoutRenderer` renders `AppAction.items` from the DECLARED element type and + * nothing else (objectui#6854, maintainer ruling of 2026-09-05, option B2). + * + * `AppAction.items` is `AppMenuItem[]` — `type` / `label` / `icon` / `path` / + * `href` / `children` / `badge` / `hidden` — and the zod mirror parses it with + * the legacy eight-member `MenuItemSchema`, which drops anything else in + * silence. This map used to reach two keys that are on neither list through + * `as any`: `onClick` and `shortcut`. + * + * Deleting them is what makes `AppActionSchema.onClick`'s refusal message true + * again. It tells an author "no renderer reads this key, so nothing could ever + * run it", and until this ruling one did — the three-layer contradiction the + * card was filed for (`?: never` on the type, "nobody reads it" from the + * validator, a renderer reading it). + * + * These assertions author BOTH undeclared keys anyway — exactly what a host + * bypassing the validator would hand in, which the card's Zone-2 census found + * none of in this repo — and require the renderer to ignore both. Re-adding + * either read turns one of them red. + * + * ⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it + * should become AUTHORABLE on `AppAction.items` is its own contract card. This + * pins the contract as it stands, not the answer to that question. + * + * The `packages/types` half of the same claim — that the refusal message still + * makes it — is pinned in + * `packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts`. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { AppComponentSchema } from '@object-ui/types'; + +// Static, module-scope import: `LayoutRenderer` pulls `@object-ui/components` +// in for the dropdown primitives, and that cost must not land inside a test's +// timeout budget (AGENTS.md 测试纪律). +import { LayoutRenderer } from '../LayoutRenderer'; + +afterEach(() => cleanup()); + +const SHORTCUT = 'Ctrl+P'; + +/** + * A user action whose items carry the two keys the declared type does not have. + * `label` and `type: 'separator'` ARE declared on `AppMenuItem`, so they are the + * control: they must keep rendering, or this file would pass by rendering + * nothing at all. + */ +function appWith(onClick: () => void): AppComponentSchema { + return { + type: 'app', + name: 'pin_app', + title: 'Pin App', + layout: 'header', + actions: [ + { + type: 'user', + label: 'Ada Lovelace', + description: 'ada@example.com', + items: [ + // `onClick` / `shortcut` are NOT on `AppMenuItem`; authored here on + // purpose, through the same cast the renderer used to read them with. + { label: 'Profile', onClick, shortcut: SHORTCUT } as never, + { type: 'separator' }, + { label: 'Sign out' }, + ], + }, + ], + } as AppComponentSchema; +} + +async function openUserMenu(app: AppComponentSchema) { + const user = userEvent.setup(); + render( + +
page body
+
, + ); + // The trigger is the avatar button; with no `avatar` URL the Radix fallback + // renders the label's initials, which is the stable accessible name here. + await user.click(screen.getByRole('button', { name: 'AD' })); + return user; +} + +describe('LayoutRenderer renders AppAction.items without reading onClick or shortcut (objectui#6854)', () => { + it('renders the declared `label` of each item — the control for the two negatives below', async () => { + await openUserMenu(appWith(vi.fn())); + expect(await screen.findByText('Profile')).toBeTruthy(); + expect(screen.getByText('Sign out')).toBeTruthy(); + }); + + it('still renders the declared `type: "separator"` item as a separator', async () => { + await openUserMenu(appWith(vi.fn())); + await screen.findByText('Profile'); + // Two separators: the one above the group, plus the authored divider item. + expect(screen.getAllByRole('separator').length).toBeGreaterThanOrEqual(2); + }); + + it('never invokes an authored `onClick` — the key the refusal message says no renderer reads', async () => { + const onClick = vi.fn(); + const user = await openUserMenu(appWith(onClick)); + await user.click(await screen.findByText('Profile')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('never puts an authored `shortcut` into the DOM', async () => { + await openUserMenu(appWith(vi.fn())); + await screen.findByText('Profile'); + expect(screen.queryByText(SHORTCUT)).toBeNull(); + expect(document.body.textContent).not.toContain(SHORTCUT); + }); +}); diff --git a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts new file mode 100644 index 0000000000..7d5b2fb992 --- /dev/null +++ b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * `AppActionSchema.onClick`'s retirement message says something FALSIFIABLE + * about this tree, so it is pinned here (objectui#6854, maintainer ruling of + * 2026-09-05, option B2). + * + * The message `handlerKeyRefusal('onClick', 'retired', …)` generates ends: + * + * > … and no renderer reads this key, so nothing could ever run it. + * + * That sentence was FALSE when this card was filed. `@object-ui/runner`'s + * `LayoutRenderer` mapped `AppAction.items` and reached `(item as any).onClick` + * — past the declared element type, which is `AppMenuItem` and has no such key + * — leaving three mutually exclusive signals for a reader to pick from: the + * TypeScript face said `?: never`, the validator said nobody reads it, and a + * renderer read it. The ruling closed that by deleting the cast rather than by + * softening the sentence, so the sentence is true again and this file is what + * keeps it that way from the `packages/types` side: edit the shared template in + * `zod/tombstone.zod.ts` and this test names what the edit costs. + * + * The renderer side is pinned where the renderer lives — + * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx` + * drives a real menu and requires an authored `onClick` never to be invoked. + * Neither pin can see the other's package, which is the point: the claim is + * about both, so it takes an assertion on each side. + * + * ⛔ NOT a pin on the template's exact prose in general — 22 other retired keys + * share it and their own message assertions live with them. This one asserts + * the clause whose truth this card measured. + */ + +import { describe, it, expect } from 'vitest'; +import { AppActionSchema, MenuItemSchema } from '../zod/app.zod'; + +/** The clause this card measured. Spelled out, not built from the template. */ +const MEASURED_CLAUSE = 'no renderer reads this key, so nothing could ever run it'; + +const onClickArm = AppActionSchema.shape.onClick; +const describeText = (onClickArm as { description?: string }).description; + +describe('AppActionSchema.onClick — the retirement message states a measured fact (objectui#6854)', () => { + it('claims, verbatim, that no renderer reads the key', () => { + expect(describeText).toContain(MEASURED_CLAUSE); + }); + + it('names the key and marks it RETIRED, not a runtime slot', () => { + expect(describeText).toContain('`onClick`'); + expect(describeText).toContain('RETIRED (objectui#6124'); + expect(describeText).not.toContain('RUNTIME SLOT'); + }); + + it('an authored value is refused BY NAME, carrying that same sentence to the author', () => { + const result = AppActionSchema.safeParse({ + type: 'button', + label: 'Quick actions', + onClick: 'openQuickActions', + }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => String(i.path[0]) === 'onClick'); + expect(issue, 'no issue addressed to `onClick`').toBeDefined(); + expect(issue!.code).toBe('custom'); + // ONE string feeds both author-facing channels (the `handlerKeyRefusal` + // invariant): the parse-time message an author reads cannot drift away from + // the `.describe()` metadata the docs surface publishes. + expect(issue!.message).toBe(describeText); + expect(issue!.message).toContain(MEASURED_CLAUSE); + }); + + it('the action without the key parses green — the refusal is about the key, not the action', () => { + expect(AppActionSchema.safeParse({ type: 'button', label: 'Quick actions' }).success).toBe(true); + }); +}); + +describe('why the cast could never have been fed by an author (objectui#6854 Zone 2, the premise)', () => { + // `AppAction.items` is parsed by the LEGACY eight-member `MenuItemSchema`, a + // plain `z.object` — so `onClick` and `shortcut` are not refused there, they + // are STRIPPED in silence. An author therefore has no declared route to send + // either key, which is what made deleting the two reads a cleanup rather than + // a behaviour removal. Whether `shortcut` SHOULD become authorable here is a + // separate contract question and deliberately not answered by this file. + const authored = { label: 'Profile', onClick: 'goProfile', shortcut: 'Ctrl+P' }; + + it('the items mirror accepts the document and drops both undeclared keys', () => { + const result = MenuItemSchema.safeParse(authored); + expect(result.success).toBe(true); + if (!result.success) return; + const parsed = result.data as Record; + expect(parsed.label).toBe('Profile'); + expect('onClick' in parsed).toBe(false); + expect('shortcut' in parsed).toBe(false); + }); + + it('a whole action carrying such an item parses green, with the item scrubbed', () => { + const result = AppActionSchema.safeParse({ + type: 'user', + label: 'Ada Lovelace', + items: [authored, { type: 'separator' }], + }); + expect(result.success).toBe(true); + if (!result.success) return; + const [first] = (result.data as { items: Record[] }).items; + expect('onClick' in first).toBe(false); + expect('shortcut' in first).toBe(false); + }); +}); diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 40f5d2a808..14d12b7ce4 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -716,10 +716,19 @@ export interface AppAction { /** * RETIRED (objectui#7344; the objectui#6182 ruling of 2026-08-25 — the * handler-expression string dialect is not a supported authoring form — in - * the objectui#6124 shape). Nothing reads `AppComponentSchema.actions[]`, so - * no value here could ever run. The zod twin refuses the key by name; author - * behaviour as a node type (an `action:button` node with a declared action) - * instead. + * the objectui#6124 shape). No renderer reads THIS KEY, so no value here + * could ever run. The zod twin refuses the key by name; author behaviour as a + * node type (an `action:button` node with a declared action) instead. + * + * Re-measured at objectui#6854, which corrects the narrower claim this + * comment used to make. `AppComponentSchema.actions[]` IS read — the standalone + * runner's `LayoutRenderer` (`@object-ui/runner`) renders both the `'button'` + * and the `'user'` arm — so "nothing reads `actions[]`" was never the reason + * this key is inert. The reason is that no reader touches `onClick`: not on + * the action, and no longer on {@link AppAction.items}, where that renderer + * reached one through an `as any` cast until the maintainer ruling of + * 2026-09-05 (option B2) deleted it. Guarded from the renderer side by + * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`. * @deprecated Not part of this contract — the value was inert. */ onClick?: never; diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 3c6609b932..c38c63bb4a 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -204,8 +204,16 @@ export const AppActionSchema = z.object({ label: z.string().optional().describe('Action label'), icon: z.string().optional().describe('Icon name'), // RETIRED (objectui#7344 — the objectui#6182 ruling: the handler-expression - // string dialect is not an authoring form; the objectui#6124 shape). Nothing - // reads `AppComponentSchema.actions[]`, so the key refuses by name. + // string dialect is not an authoring form; the objectui#6124 shape). The + // `'retired'` arm's message tells an author "no renderer reads this key, so + // nothing could ever run it"; objectui#6854 re-measured that sentence rather + // than restating it. `actions[]` itself IS read (`@object-ui/runner`'s + // `LayoutRenderer` renders the `'button'` and `'user'` arms) — the earlier + // "nothing reads `actions[]`" here was wrong — but no reader touches + // `onClick`, on the action or on `items[]`, since the maintainer ruling of + // 2026-09-05 (option B2) deleted the two `as any` reads that did. Text pinned + // in `__tests__/app-action-onclick-refusal-6854.test.ts`; the renderer side in + // `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`. onClick: handlerKeyRefusal('onClick', 'retired', 'Click handler'), avatar: z.string().optional().describe('User avatar URL (for type="user")'), description: z.string().optional().describe('Additional description (e.g., email for user)'),