Skip to content

fix(components): forward RadioGroupSchema.orientation to the rendered radio group - #6244

Merged
yinlianghui merged 3 commits into
mainfrom
claude/issue-6158-radiogroup-orientation
Aug 25, 2026
Merged

fix(components): forward RadioGroupSchema.orientation to the rendered radio group#6244
yinlianghui merged 3 commits into
mainfrom
claude/issue-6158-radiogroup-orientation

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6158

RadioGroupSchema.orientation was declared in two layers and read by none. This forwards it to the underlying Radix RadioGroup, restoring declared = enforced without widening the acceptance set — no new key becomes legal, and no spelling outside the already-declared enum is accepted.

What was re-derived, not assumed

All four of the dispatch's mechanism assumptions were checked against this branch's tree before any code was written. All four held, but two of them moved:

AssumptionVerdict
Renderer is packages/components/src/renderers/form/radio-group.tsx✅ Confirmed. Registration spans lines 15–67; the file contained neither the string orientation nor direction.
orientation declared on the TS type and the zod mirror, with enum + default✅ Confirmed, but the card's line numbers point at dist/. The source sites are packages/types/src/form.ts:383 (orientation?: 'horizontal' | 'vertical', @default 'vertical') and packages/types/src/zod/form.zod.ts:282 (z.enum(['horizontal', 'vertical']).optional()).
Radix accepts orientation natively with the same vocabulary✅ Confirmed against node_modules, not memory: installed @radix-ui/react-radio-group@1.4.7, whose RadioGroupProps.orientation is RovingFocusGroupProps['orientation'] = AriaAttributes['aria-orientation'] = 'horizontal' | 'vertical'. Exact match — so this is a real forward, not a silent no-op.
Registry meta inputs omits orientation✅ Confirmed — it listed only defaultValue, id, options, className.

On the default. The declared default is 'vertical', and it matters: the unfixed renderer's grid gap-2 stack already looked vertical, so it accidentally matched the vertical state visually — but it emitted no data-orientation and no aria-orientation, so in the DOM it matched neither state. That is why the pin asserts attributes and not just appearance.

The change

  • schema.orientation is forwarded by name, not by reopening the DOM spread. toFormControlDomProps is a closed whitelist that does not carry orientation, and forwarding-by-name is exactly the objectui#4435 route that file documents for a key like this.
  • The declared @default 'vertical' is applied rather than left to Radix's own undefined. A default the type documents and nothing applies is the same declared-but-unenforced defect as the key itself.
  • The layout utilities follow the key, because ## Layout Options promises a visible difference. Author className composes last, so tailwind-merge resolves every conflict in the author's favour.
  • Registry meta inputs gains orientation, in the house type: 'enum' shape (matching overlay/sheet.tsx:48, which likewise carries a defaultValue). This is the third surface the card measured as omitting the key.

Behaviour note for existing radio groups

A group that never authored the key keeps the vertical stack it already rendered, and now additionally announces aria-orientation="vertical" — the announced orientation agrees with the rendered one instead of being absent. Arrow-key roving focus narrows from all-arrows to Up/Down for those groups, which is the correct pairing for a vertical stack. Called out here because it is the one user-visible change that reaches groups which did not opt in.

⚠️ Serial boundary with #6157 — the shared docs demos still render identically

This branch deliberately does not touchexamples/schema-catalog/src/schemas/components-form-radio-group/*. Those fixtures — including horizontal-layout.json and vertical-layout.json, the two demos behind the docs page's ## Layout Options heading — currently spell the key direction, which nothing declares. That divergence is #6157's scope.

Consequence, stated plainly: with this PR alone, the two shipped docs demos still render identically to each other. The renderer can now tell the orientations apart; those two fixtures still do not ask it to. Triage asked that "the horizontal demo's markup actually diverges once both land" be asserted — that assertion is not possible from this branch and is not attempted here. The pin asserts divergence against its own fixtures instead. This is the serial boundary, not an incomplete card; the docs page becomes correct when #6157 lands.

Verification

Pin written first and proven red on the unfixed renderer before the fix existed, then re-run as an ablation against the final tree so the red measurement matches what is shipping.

Ablation restored the renderer to the branch point with git checkout f66072d1b -- packages/components/src/renderers/form/radio-group.tsx, under trap … EXIT INT TERM. Mutation proven on disk in both directions — orientation occurrences 10 → 0, ORIENTATION_CLASS→ 0, 1 file changed, 1 insertion(+), 34 deletions(-); git diff HEAD --stat empty after restore:

 × renders DIFFERENT markup for the two orientations
× carries the authored orientation onto the radiogroup root
× lays the horizontal group out as a row and the vertical group as a stack
× enforces the declared `@default 'vertical'` when orientation is omitted
Tests 4 failed | 1 passed (5)

The core failure is the defect itself: an AssertionError reporting that the horizontal root's markup not to be the vertical root's markup, with the two truncated strings printed identically — Object.is equality on two roots that are character-for-character the same.

With the fix: Test Files 1 passed (1) / Tests 5 passed (5).

Which assertions would still pass on a revert

Stated because a pin that is green either way pins nothing:

  • lets an author className still win over the orientation layout classes — passes on a revert. It asserts gap-8 present and gap-4 absent; on the unfixed renderer no gap-4 is ever emitted, so it is vacuously true. Kept because it guards the composition order going forward, but it is not evidence for this card.
  • One half of enforces the declared @default 'vertical' — passes on a revert. The byte-identity of the omitted case against explicit vertical held before the fix too, because both were orientation-less. The data-orientation/aria-orientation half of that same test is red on a revert, and that is the half that does the work.
  • The other three tests are red on a revert.

Gates run (all on fbc603df7, the final commit; tree clean)

Derived by enumerating each CI job's own step list, then re-run as a union after the last commit.

  • pnpm test (targeted): packages/components/186 files / 1706 tests passed; plus the three suites that reference radio-group (widget-dom-leak-sweep, form-control-dom-leak-5632, form-renderers) — 239 passed. The DOM-leak sweep passing matters: the two new attributes are not read as leaks.
  • type-check: @object-ui/components green, plus the dependent direction — every one of the 30 dependent packages that has a type-check script ran a real tsc and passed. (@object-ui/example-hello-world has no such script and says so loudly — ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT, exit 1 — rather than exiting 0 on a zero-script match.)
  • lint: @object-ui/components with --force (Cached: 0 cached) — 0 errors, 907 pre-existing warnings.
  • check:designer-field-key-parity, check:doc-types, check:doc-snippets, check:control-bytes, check:phantom-deps, check:self-import, check:action-forward-parity, check:icon-record-names, check:i18n-keys, check:doc-fences — all green.
  • check:eager-closure — first run exited 2 ("broken gauge, not a passing budget") because it needs a console build; built and re-run rather than counted as a pass: Console eager closure 3222.6 KB / 3990.2 KB budget, and ui-components 382.0 KB / 389.6 KB ceiling.
  • check-changeset-presence / check-changeset-no-major — green. Changeset is minor (never major, per the fixed-group rule).

Two local red readings were chased down and are not regressions — both were unbuilt local dependencies, proven by building them and re-running to green: @object-ui/console (missing plugin-gantt / plugin-map builds) and @object-ui/site (missing example-schema-catalog build).

Scope

Bounded to the radio-group renderer, its registry meta entry, its changeset, and one new test file. packages/components/src/ui/radio-group.tsx is untouched — the shadcn wrapper already spreads its remaining props after className, so the prop reaches Radix without editing a no-touch file. Nothing needed to reach wider.

⛔ Left draft deliberately — the PM lands it.


Generated by Claude Code

Red on the current renderer, for the reason the card measured: the two
rendered radiogroup roots come back character-for-character equal, because
`RadioGroupSchema.orientation` is declared by the TS type and the zod mirror
and read by nothing.
The pin asserts the INEQUALITY rather than "horizontal renders something" —
the latter is true before and after any fix and would pin nothing. Fixtures
are authored here rather than reused from the schema catalog, whose
radio-group fixtures still spell the key `direction` (objectui#6157).
Part of #6158
… group
`orientation` was declared in two layers and read by none: the shipped TS
type (`@default 'vertical'`, enum) and the zod mirror both carry it, while the
renderer contained neither the string `orientation` nor `direction`. Every
radiogroup root the library rendered was therefore byte-identical on that axis
— no `data-orientation`, no `aria-orientation` — so the docs page's
`## Layout Options` section demonstrated a distinction the product could not
make, and the horizontal demo rendered vertically.
Radix's `RadioGroup` accepts `orientation` natively with the same two-value
vocabulary (verified against the installed 1.4.7: `RovingFocusGroupProps
['orientation']` = `AriaAttributes['aria-orientation']`), and puts it on the
root as `aria-orientation` plus, via RovingFocusGroup, `data-orientation`. It
is forwarded BY NAME rather than by reopening the DOM spread —
`toFormControlDomProps` is a closed whitelist and this is the objectui#4435
route that file documents.
The declared `@default 'vertical'` is applied here rather than left to Radix's
own `undefined`: a default the type documents and nothing applies is the same
declared-but-unenforced defect as the key itself. Vertical is also what the
group has always looked like, so this makes the announced orientation agree
with the rendered one instead of being absent.
The layout utilities follow the key, since `## Layout Options` promises a
visible difference; author `className` composes last so tailwind-merge
resolves conflicts in the author's favour. Registry meta `inputs` gains
`orientation` in the same change — the third surface that omitted it.
Part of #6158
… changeset
`as const` made `options` a readonly tuple, which `RadioGroupSchema.options:
RadioOption[]` rejects, and a `satisfies Omit<…>` widened `type` back to
`string`. A factory with a return-type annotation contextually types both, and
carries the "differ in EXACTLY one key" property in the code rather than in a
comment beside it.
Part of #6158
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.6 KB3266.6 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-BJY1L29S.js
StatusPASS

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

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.32KB42.81KB
plugin-detail (index.js)244.13KB61.93KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.07KB30.78KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@yinlianghui
yinlianghui marked this pull request as ready for review August 25, 2026 04:11
@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 6fd2cf7Aug 25, 2026
27 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-6158-radiogroup-orientation branch August 25, 2026 04:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui@claude