Skip to content

fix(overlay): MenuItem discriminated union + declared onClick across dropdown-menu/context-menu/menubar - #6693

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-6523-menuitem-separator-family
Aug 28, 2026
Merged

fix(overlay): MenuItem discriminated union + declared onClick across dropdown-menu/context-menu/menubar#6693
os-sales merged 1 commit into
mainfrom
claude/issue-6523-menuitem-separator-family

Conversation

@os-sales

@os-sales os-sales commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6523
Fixes #6346

Summary

MenuItem (shared by ui:dropdown-menu, ui:context-menu, ui:menubar) is now a
discriminated union, and all three menu renderers read exactly the keys it declares — no
more, no less. This lands both cards' rulings together (maintainer ruling 2026-08-27, "one
answer for the whole MenuItem family"), each keeping its own Fixes line and pins below.

#6523 — B (the union) then A (the renderer fix) then the rider

B — MenuItem becomes MenuCommandItem | MenuDividerItem (packages/types/src/overlay.ts,
mirrored in packages/types/src/zod/overlay.zod.ts). A command item keeps label: string
required; a divider is { separator: true } alone. The union, not label?: string: it is
what the data actually is, and it keeps every command item's label protection intact rather
than weakening it repo-wide to accommodate the divider arm. Both arms tombstone type
(type?: never / z.never().optional()) — the retired { type: 'separator' } (and its
sibling { type: 'label' }, the same key) is now a declared refusal, not a silent strip.

Before/after, measured directly:

MenuItemSchema.safeParse({ separator: true })
  before: success: false, issues[0].path: ['label']
  after:  success: true                              <- green for the first time
MenuItemSchema.safeParse({ label: 'x', type: 'separator' })
  before: success: true,  data: { label: 'x' }        <- silently stripped
  after:  success: false                              <- declared refusal

A — dropdown-menu and context-menu branch on the declared item.separator, matching
menubar (which had this right all along — the evidence the type was correct). Their
registry defaultProps and description strings stop teaching { type: 'separator' }.

Re-derived count: 4 occurrences of { type: 'separator' } in this repo, now migrated:

  • examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/basic-dropdown-menu.json
  • examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json
  • packages/components/src/renderers/overlay/dropdown-menu.tsx (registry defaultProps)
  • packages/components/src/renderers/overlay/context-menu.tsx (registry defaultProps)

(2 schema-catalog fixtures + 2 registry defaultProps = 4, matching the ruling's count. No
other dropdown-menu/context-menu catalog fixture authors it — only 3 files in the whole
catalog use either type at all, and only these 2 carry a separator.) A matching test-fixture
literal in context-menu-item-icon.test.tsx (mirroring basic-context-menu.json) is migrated
too.

⛔ Stop condition checked, not triggered. No { type: 'separator' } (or type: 'label')
exists anywhere outside this repo that this session can measure — this repo's own corpus is
the only evidence available, and it is fully migrated. C (retiring separator in favor of
type) is not the reconsideration here.

Counter-probe flipped, as the ruling asked. examples/schema-catalog/test/component-fixture-declared-keys.test.ts's
"no parse can see the separator defect, in either direction" probe used to assert .success
was blind (stripped type, rejected the declared divider). It now asserts the fixed
behaviour in both directions — .safeParse is no longer blind, and the file's header
explains why the structural, corpus-walking sweep above it is still the right instrument for
what a .success probe can never replace (auditing what a fixture actually AUTHORS, not what
a hand-built probe object parses to).

Rider — menubar renders shortcut (parity, not new capability). The declared string
key already had working runtime in the other two containers; menubar read it nowhere. Aligns
the third container.

#6346 — the item handler moves to the declared key

All three renderers now fire item.onClick — declared in TS source, the built .d.ts, and
the Zod mirror all along, but never read. dropdown-menu/context-menu used to read an
undeclared item.onSelect instead (Radix's own callback prop name, now wired to invoke
item.onClick); menubar wired no handler at all, neither spelling. renderMenuItems/
renderContextMenuItems tighten from items: any[] to items: MenuItem[] — the widening
that let the mismatch type-check in the first place.

Migration cost: measured zero in this repo — no fixture, doc or test authored
onSelect on a menu item before this change (functions aren't JSON-authorable in the first
place, so this is a pure renderer/test-literal concern, not a catalog-migration one).

Consequential cleanup (forced by the any[]MenuItem[] tightening, not a separate decision)

  • item.type === 'label' (a third, equally undeclared dialect on the same type key,
    never authored anywhere in this repo's docs or fixtures) is removed from both renderers —
    once type is a declared never tombstone, item.type === 'label' no longer type-checks
    (undefined has no overlap with the string literal 'label'), so the branch became dead
    code the moment type retired. DropdownMenuLabel/ContextMenuLabel's section-heading
    affordance is unaffected — those components are still used for schema.label on the whole
    menu, just not per-item any more.
  • item.inset (an undeclared key, never taught in any doc or fixture, never authored) is
    dropped from both renderers' DropdownMenuItem/DropdownMenuSubTrigger and
    ContextMenuItem/ContextMenuSubTrigger props — it doesn't exist on MenuItem and had no
    live author anywhere to preserve.

Docs

content/docs/components/overlay/{dropdown-menu,context-menu,menubar}.mdx's ## Schema
blocks are corrected for exactly what this PR changes: the type?: 'separator' line becomes
the union shape, onClick is added to the command item (menubar's array-valued shortcut?: string[] is corrected to the declared string), and the bogus schema-level onSelect?: string | ActionConfig line (never a real field on any of the three schemas, and directly
confusable with the #6346 finding) is removed. Pre-existing inaccuracies these blocks also
carry (value/variant fields MenuItem never declared, MenubarSchema.menus taught as
required when it's optional) are left untouched — that is open issue #6521's scope
(21 value occurrences across 3 fixture families), not this PR's; #6521 itself says to
"align with whichever way #6523 is ruled," which this PR's menubar.mdx edit now is.

Tests (all new, all pass)

  • packages/types/src/__tests__/menu-item-union.test.ts — the union and its type tombstone
    at both the TS level (@ts-expect-error pins routed through a named, non-fresh value so
    the failure is provably the never assignability check and not excess-property checking —
    see the file header) and the zod level (MenuItemSchema.safeParse pins for both directions).
  • packages/components/src/__tests__/menu-item-separator-dialect.test.tsx — the divider
    renders in both dropdown-menu and context-menu (the ruling's required
    absent-on-declared-spelling / blank-row-regression pins), the retired spelling no longer
    draws anything, and menubar's shortcut rider.
  • packages/components/src/__tests__/menu-item-onclick-handler.test.tsxonClick fires in
    all three containers (including a menubar submenu child), and the undeclared onSelect no
    longer does.
  • examples/schema-catalog/test/component-fixture-declared-keys.test.ts — flipped counter-probe
    (above) plus a recalibrated "declared keys survive the schema" control (split into a
    command-arm probe and a divider-arm probe, since one combined object can no longer match a
    union with two mutually-exclusive separator values).

Ablation (each leg predicted before it ran, mutation and restoration both proven on disk)

Leg A — revert only the divider-dialect fix (item.separator branch → old undeclared
item.type === 'separator'/'label' branches) in dropdown-menu.tsx/context-menu.tsx.
Predicted: the 6 divider/blank-row tests in menu-item-separator-dialect.test.tsx go red (3
per container — divider-renders, no-blank-row, retired-spelling-no-longer-draws — the last one
red because the OLD code still draws it), the 2 menubar shortcut tests stay green (menubar
untouched by this leg). Observed: exactly that — 6 failed, 2 passed. Mutation confirmed on
disk by anchored grep -c before/after; restoration confirmed by git diff HEAD empty after
git checkout HEAD -- PATH.

Leg B — revert only the onClick-wiring fix (onSelect={() => item.onClick?.()} → the old
onSelect={item.onSelect} passthrough for dropdown-menu/context-menu; the onClick wiring
removed entirely — both the top-level item and the submenu child — for menubar). Predicted:
all 6 tests in menu-item-onclick-handler.test.tsx go red (authored onClick never fires;
authored onSelect, which the old dropdown/context code wired directly, DOES fire, inverting
the "never invoked" assertion). Observed: exactly that — 6 failed, 0 passed, after fixing
one script bug on the first attempt (the menubar submenu-child mutation regex initially missed
because that comment spans 3 lines rather than 1, leaving 1 stale wiring call and 1 green test
where I'd predicted red — a bug in the ablation harness, not a finding about the renderer;
corrected and re-run to full 6/6 red with anchored counts confirming 0 remaining occurrences).
Restoration confirmed by git diff HEAD empty.

Gate table (all at c75b928e6)

Gate Result
pnpm --filter @object-ui/types type-check ✅ (tsc --noEmit && tsc -p tsconfig.examples.json && tsc -p tsconfig.test.json, exit 0)
pnpm --filter @object-ui/components type-check ✅ (tsc --noEmit && tsc -p tsconfig.test.json, exit 0)
pnpm exec vitest run packages/types/ ✅ 69 files / 828 tests
pnpm exec vitest run packages/components/ ✅ 202 files / 1837 tests
pnpm exec vitest run examples/schema-catalog/test/component-fixture-declared-keys.test.ts ✅ 33 tests
node scripts/check-control-bytes.mjs
node scripts/check-doc-component-types.mjs
node scripts/check-doc-fence-languages.mjs
node scripts/check-lucide-icon-record-names.mjs ✅ (unaffected by this diff; run because the renderer files import resolveIcon)
node scripts/check-changeset-presence.mjs ✅ 1 changeset declared
node scripts/check-changeset-no-major.mjs
pnpm --filter @object-ui/types eslint (touched files) ✅ 0 errors, 1 pre-existing-pattern warning (z.ZodType (any-parameterized), unchanged annotation style)
pnpm --filter @object-ui/components eslint (touched files) ✅ 0 errors, 12 no-explicit-any warnings, all on pre-existing [key: string]: any prop-spread signatures or the same React.ComponentType (any-parameterized) test-helper pattern already used by sibling test files
node scripts/check-doc-snippet-types.mjs ⚠️ NOT MEASURED — PRECONDITION NOT MET (16 unrelated packages, e.g. @object-ui/app-shell, not built in this fresh worktree). This gate only compiles ts/tsx fences; every block this PR touches is plaintext, unchanged from before, so this diff cannot be implicated. Pre-existing, not attempted to force-build the whole plugin family for an unaffected gate.

Out-of-scope, for the record


Generated by Claude Code

…renderers read the declared onClick

MenuItem (dropdown-menu/context-menu/menubar) is now MenuCommandItem | MenuDividerItem:
a command item with a required label, or `{ separator: true }` with none. `type` is
tombstoned (`?: never` / `z.never().optional()`) on both arms, retiring the undeclared
`{ type: 'separator' }` / `{ type: 'label' }` dialect dropdown-menu and context-menu used
to read instead of the declared `separator` boolean.

dropdown-menu and context-menu now branch on `item.separator` (matching menubar, which
always had this right) instead of the undeclared `item.type`; their registry defaultProps
and description strings stop teaching the retired spelling. All three renderers now fire
the declared `item.onClick` — dropdown-menu/context-menu used to read an undeclared
`item.onSelect` instead, and menubar wired no handler at all.
`renderMenuItems`/`renderContextMenuItems` tighten from `items: any[]` to `MenuItem[]`,
closing the hole that let the mismatch type-check. menubar also gains `shortcut` rendering
(parity with the other two containers, not new capability).

Fixes #6523
Fixes #6346

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 49 chunks) 3231.7 KB 3266.6 KB
Main entry chunk (gzip) 157.2 KB 350 KB
Entry file index-1AZGMvFo.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 11.89KB 4.50KB
app-shell (runtime-config.js) 20.61KB 7.35KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 509.32KB 115.60KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 239.05KB 60.06KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.85KB 12.89KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 190.33KB 45.10KB
plugin-dashboard (index.js) 133.43KB 34.48KB
plugin-designer (index.js) 212.80KB 43.15KB
plugin-detail (index.js) 245.29KB 62.39KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.01KB 32.23KB
plugin-gantt (index.js) 165.16KB 40.33KB
plugin-grid (index.js) 201.51KB 54.54KB
plugin-kanban (index.js) 53.11KB 14.62KB
plugin-list (index.js) 113.01KB 27.57KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 26.44KB 7.59KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 85.87KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 65.97KB 21.98KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 2.44KB 1.21KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.72KB 2.24KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants