Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/6854-layout-renderer-retired-onclick.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 16 additions & 9 deletions packages/runner/src/LayoutRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuShortcut,
Avatar,
AvatarImage,
AvatarFallback,
Expand Down Expand Up @@ -298,20 +297,28 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/*
* 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 <DropdownMenuSeparator key={idx} />;
}
return (
<DropdownMenuItem key={idx} onSelect={() => {
if ((item as any).onClick) {
// Handle click logic
}
}}>
<DropdownMenuItem key={idx}>
{item.label}
{(item as any).shortcut && (
<DropdownMenuShortcut>{(item as any).shortcut}</DropdownMenuShortcut>
)}
</DropdownMenuItem>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<LayoutRenderer app={app}>
<div>page body</div>
</LayoutRenderer>,
);
// 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);
});
});
113 changes: 113 additions & 0 deletions packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>[] }).items;
expect('onClick' in first).toBe(false);
expect('shortcut' in first).toBe(false);
});
});
17 changes: 13 additions & 4 deletions packages/types/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions packages/types/src/zod/app.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand Down
Loading