Skip to content

The three menu renderers run two different separator dialects against one MenuItem, and the declared one cannot parse green #6523

Description

@os-support-ai

Found while implementing #6249 (repairing the menubar demo's separators to the declared separator: true spelling). Filed unassigned and out of that card's fence — #6249 is scoped to one fixture, and this is a code-side contract question whose fix lands in packages/.

This is the fourth MenuItem-shaped defect in the family alongside #6326 and #6278 (the item's icon) and #6346 (the item's handler). Unlike those it is about the divider and the shortcut.

What was measured, at 50f987f9a

MenuItem is one interface, shared by dropdown-menu, context-menu and menubar items. The three renderers do not agree on how to read it.

1. Two separator dialects, and only one of them is declared.

// packages/components/src/renderers/overlay/menubar.tsx:33
item.separator ? (                                   // the DECLARED key

// packages/components/src/renderers/overlay/dropdown-menu.tsx:46
if (item.type === 'separator') return <DropdownMenuSeparator key={i} />;   // UNDECLARED

// packages/components/src/renderers/overlay/context-menu.tsx:44
if (item.type === 'separator') return <ContextMenuSeparator key={i} />;    // UNDECLARED

MenuItem declares separator?: boolean (packages/types/src/overlay.ts:358, zod/overlay.zod.ts:139) and declares no type at all. MenuItemSchema is a bare z.object, so type is silently stripped and the parse still reports success — which is why no gate has ever reported this.

Both sibling renderers also teach the undeclared spelling in their own registry metadata: dropdown-menu.tsx:124 and context-menu.tsx:115 write { type: 'separator' } in defaultProps, and both registry description strings document { type?: "separator"|"label", ... }. menubar.tsx:69 writes the declared { separator: true }. The registry itself ships both dialects.

2. shortcut is rendered by two of the three.

// dropdown-menu.tsx:70
{item.shortcut && <span className="ml-auto text-xs tracking-widest opacity-60">{item.shortcut}</span>}

// context-menu.tsx:68
{item.shortcut && <ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>}

menubar.tsx reads it nowhere.

This matters to a ruling already made. #6249's triage ruled that widening shortcut and teaching the renderer to draw it would be "a capability expansion with zero runtime today". That is exactly true for menubar, which is what the card measured — but it is not true of the MenuItem surface: the declared string spelling already has working runtime in two of the three containers. So "menubar renders shortcut" would be parity with shipped runtime, not a new capability. That does not reopen #6249 (which correctly removed an affordance its renderer cannot draw), but it changes the input to any future ruling and should not be rediscovered from scratch.

3. The declared separator spelling cannot parse green.

MenuItem.label is requiredlabel: string (overlay.ts:334), z.string() (overlay.zod.ts:133) — and a divider has no label. Measured:

MenuItemSchema.safeParse({ separator: true })
// => success: false, issues[0].path: ['label']

menubar.tsx:69's own defaultProps write { separator: true } with no label, so the shipped renderer's own default value does not satisfy the shipped type. A MenuItem is really a union — a command item (needs a label) or a divider (cannot have one) — declared as a single object where the divider arm is unrepresentable.

This is not theoretical: it directly shaped #6249. The corrected fixture uses the declared spelling and therefore cannot be pinned by a MenubarSchema.safeParse, so the assertion there had to be structural. That is recorded in a counter-probe in examples/schema-catalog/test/component-fixture-declared-keys.test.ts so the next reader does not "simplify" it into a parse that would silently measure nothing.

Why it matters

Declared and enforced disagree in both directions on the same object, the failure mode #6132's four-axis analysis called the most expensive ("能通过校验、能发布、然后静默什么都不做"):

The decision this needs

A — the type is right, the renderers are wrong. Teach dropdown-menu and context-menu to branch on item.separator, and correct their defaultProps and registry descriptions. Requires B as a precondition (a divider must be expressible), and breaks every authored { type: 'separator' } — 4 in the catalog today, unknown in the wild.

B — make the declared divider representable. Either label?: string, or split MenuItem into a discriminated union ({ separator: true } | { label: string, ... }). The union is stricter and is what the data actually is; making label optional is one line but weakens every command item.

C — the renderers are right, the type is wrong. Declare type?: 'separator' | 'label' on MenuItem and retire separator. This moves a published type and would make menubar.tsx the odd one out instead.

I have no recommendation strong enough to skip triage. A+B matches the declaration and AGENTS.md #0.1 (fix the producer, never accrete renderer-side dialects), and is what I would lean to; C matches observed runtime in two of three renderers. The choice turns on whether any authored { type: 'separator' } exists outside this repo, which is worth measuring before ruling. Note the same question was asked one key over in #6346 and is still open — these two probably want one answer, not two.

Refs: #6249 · #6346 · #6326 · #6278 · #6132 · #5250.


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:queue

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions