Skip to content

refactor(components): resolve ui:button icons through the shared resolver - #6350

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5993-button-inlined-icon-resolver
Aug 25, 2026
Merged

refactor(components): resolve ui:button icons through the shared resolver#6350
os-support-ai merged 1 commit into
mainfrom
claude/issue-5993-button-inlined-icon-resolver

Conversation

@claude

@claudeclaudeBot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes#5993

ui:button resolved its authored icon with a hand-copied reimplementation of the shared
resolver. It now imports resolveIcon from renderers/action/resolve-icon.ts, and the
copy is gone.

This is a duplication repair, not a rendering fix. Nothing a user sees changes — that
is the contract, and the verification below is built around proving it rather than around
a defect that does not exist.


1. Premise, re-derived on origin/main @ 79ebf30d1

Line numbers read off the tree on this branch's merge base rather than inherited from the
card (two rows of the dispatched table were off by one; the substance was exact).

shared — renderers/action/resolve-icon.tscopy — renderers/form/button.tsx
function toPascalCase (:14-19)function toPascalCase (:18-23)
const iconNameMap = { Home: 'House' } (:22-24)const iconNameMap = { 'Home': 'House' } (:26-28)
export function resolveIcon (:30-35)inline at :44-49

Both split on -, both PascalCase each segment, both consult the same single-entry rename
map, both index lucide's runtime icons record. Same algorithm, not the same function —
so an alias added to resolve-icon.ts to absorb a lucide retirement (the objectui#5586 /
objectui#5622 mechanism) reached every action:* site, complex/data-table.tsx and both
menu renderers, and silently missed this one.

Helper census before deleting anything.toPascalCase is referenced exactly twice in
the file — its definition (:18) and the icon lookup (:46). iconNameMap likewise
(:26, :47). Neither served anything but the icon lookup, so both go with it.

2. Equivalence — measured, not assumed

The deleted copy was transcribed verbatim and run beside the real shared resolver over
3547 names: every one of lucide's 1767 record keys in both its PascalCase and its
kebab-case spelling, plus kebab-case probes, the rename alias, retired spellings, the empty
string and undefined.

population : 3547 names (record keys 1767 x2 + 13 named)
identical (===) : 3539
nullish-only diff : 8 (copy -> undefined, shared -> null)
GENUINE FORKS : 0
nullish-diff names: "not-a-real-icon", "edit", "smile", "alert-triangle",
"arrow-down-az", "arrow-down-za", "arrow-up-az", "arrow-up-za"
-- named probes (copy | shared) --
"arrow-right" ArrowRight | ArrowRight same-object
"dollar-sign" DollarSign | DollarSign same-object
"user-plus" UserPlus | UserPlus same-object
"home" House | House same-object
"Home" House | House same-object
"not-a-real-icon" undefined | null DIFFER
"edit" undefined | null DIFFER
"" null | null same-object
undefined null | null same-object

Zero genuine forks. Agreement is by object identity, not by name — the same component
object comes back from both.

The one real difference, and why it is inert — measured, not assumed

The copy ended in (icons as any)[mapped] as LucideIcon, which yields undefined for
a miss; resolveIcon ends in ?? null. The card flagged this as the difference to check,
so it was checked at the consumption site rather than reasoned about:

Icon occurs at exactly four places in the old file — the declaration (:44), the
assignment (:48), and two render sites:

{!isLoading&&Icon&&schema.iconPosition!=='right'&&<IconclassName="mr-2 h-4 w-4"/>}{!isLoading&&Icon&&schema.iconPosition==='right'&&<IconclassName="ml-2 h-4 w-4"/>}

Both are truthiness tests, and React renders nothing for null and undefined alike,
so the flavour of the falsy value never reaches the DOM. Pinned behaviourally by three rows
in the new suite (a retired spelling, an unresolvable name, and no icon authored — all
three assert button.querySelector('svg') is null while the label still renders).

One consequence worth stating: the new code calls resolveIcon(schema.icon)
unconditionally, where the copy skipped the lookup when schema.icon was falsy.
resolveIcon returns null for undefined and for '' by its own if (!name) guard, so
the outcome is identical; the guard simply moved inside the function.

3. Removal proof

Counts taken with git show 79ebf30d1:<path> against the working file:

grepbeforeafter
^function toPascalCase10
^const iconNameMap10
icons imported from lucide-react10
(icons as any)[…] index10
import from '../action/resolve-icon'01
resolveIcon( call01
file length106 lines93 lines

No second definition of either helper survives anywhere in the file. The two textual
occurrences that remain are inside the explanatory comment naming what was removed, and
they are prose — the census in scripts/check-lucide-icon-record-names.mjs parses imports
from the TS AST, so a comment cannot be mistaken for a read.

4. Why there is no red-before ablation, and what red-before there is

Behaviour is unchanged by construction, so the usual "revert the fix, watch the test go
red" leg does not exist for the behavioural rows: they are green on the copy and green on
the import. No fake behavioural difference was manufactured to produce one. Presenting
those green rows as evidence that the change is correct would be circular; they are the
guard that it changed nothing.

What is checkable was checked, in two reverse-verification legs. Each restored the
pre-change state on disk, proved the mutation landed by blob hash and by grep before
reading anything, and restored via git checkout HEAD -- <abs path> under a trap, with
git diff HEAD empty and the blob hash back to HEAD's afterwards.

Leg A — restore the inlined copy, keep the new suite (79ebf30d1's button.tsx; blob
d1d0a278e6377fe9, copy defs on disk: 2, shared-resolver imports: 0):

× resolves the authored name through the SHARED resolver
× renders the glyph the shared resolver returned, not one of its own
Tests 2 failed | 9 passed (11)
AssertionError: expected "vi.fn()" to be called with arguments: [ 'arrow-right' ]
Number of calls: 0

The two routing rows are the only ones that discriminate, and they discriminate on
exactly the thing this card changes: which function the glyph came out of. The nine
behaviour rows stayed green in both worlds, as they must.

That leg also caught a blind instrument in the suite before it shipped: the second routing
row originally read expect(shared.mock.results[0]?.value).not.toBeNull(), which passes
with zero calls (undefined is not null) and was measured green against the restored
copy. It now asserts toHaveBeenCalledTimes(1) first, and the reason is written beside it.

Leg B — restore the census declaration while the source no longer reads the record
(blob 1a591a766bd2ec8d, injected declaration lines: 1):

FAIL lucide icon names
- STALE record-reading resolver census entry:
packages/components/src/renderers/form/button.tsx no longer reads that
vocabulary (or moved). Update the census — do not delete the gate.
exit 1

5. The icon-name gate — what its verdict means here

scripts/check-lucide-icon-record-names.mjsdoes cover this file, and it is not a
bystander: part 1 of that gate rediscovers every record-reading module from source on each
run and fails when the discovered set differs from DECLARED_RECORD_READERSin either
direction
. Dropping form/button.tsx from that list is therefore verified rather than
asserted — leg B above is that verification, and the run on this branch reports the
population as 7 resolvers, down from 8.

Two things it says, and one it does not:

  • ✅ It confirms the removal. A declaration that outlived the read fails as STALE (leg B).
  • ✅ It now guards the dedupe going forward. A re-inlined copy would be discovered as an
    undeclared record reader and fail the census in the opposite direction — which is a
    stronger guard against re-divergence than any single test.
  • ⛔ It was blind to the duplication itself, and would have stayed green forever with
    the copy in place. Both modules were declared legitimate record readers; nothing in the
    gate compares two resolvers to each other. That blindness is exactly what the card was
    filed about, and it is disclosed in objectui#5935.

The part-2 census entry for the buttontype stays — its authored icon names still
reach a record-reading resolver, one indirection away — with its resolver field
re-pointed at resolve-icon.ts, matching the context-menu / dropdown-menu idiom
already in that table.

Three rows of scripts/__tests__/check-lucide-icon-record-names.test.ts hard-coded the
population at 8 and enumerated form/button.tsx among the four resolvers objectui#5633's
hand-kept table missed. They move to 7, with the reason recorded in place: the site left
the census by being fixed, not by being forgotten, and the row that listed it now asserts
its absence so the direction of the change is pinned rather than merely edited away.

6. Out of scope — reported, not touched

renderers/basic/icon.tsx (ui:icon) keeps its own copy, deliberately. It draws a
SquareDashed placeholder and warns on an unresolvable name (objectui#5631, maintainer
ruling 2026-08-22), where the shared resolveIcon returns null. Folding it in would
delete a deliberate behaviour and break
renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx. Untouched here, and the
reason is now recorded beside the census pin that counts it.

A fourth production copy exists that the dispatched census did not name:
packages/plugin-view/src/ViewSwitcher.tsx (:149-165) carries its own toPascalCase,
its own iconNameMap and its own function also calledresolveIcon. It is the same
duplication class as this card — and plugin-view already depends on @object-ui/components,
so it has no structural obstacle to importing the shared one.

Both sites are already inside the scope of the open umbrella card objectui#5935
("Consolidate the eight lucide record-reading icon resolvers into one seam"), which
enumerates form/button.tsx among its eight and rules on the tokeniser and alias
divergences the wider consolidation has to settle. No new issue was filed, because that
search found one. This PR is the first slice of that population to land, and it is the
easy slice precisely because ui:button's tokeniser and alias map were already identical
to the shared resolver's — which is why it could be done without changing behaviour.
objectui#5935 stays open.

7. Gates

Gate set derived by reading the step lists under .github/workflows/ (ci.yml, lint.yml,
changeset-*.yml, control-bytes.yml, vi-mock-specifiers.yml), not from memory. Exit
codes captured before any pipe; each verdict line is the gate's own output.

Run at f72ac931 — the final commit, working tree clean.

gateexitits own verdict line
pnpm check:icon-record-names0OK lucide icon names: 167 authored/declared names reaching 7 record-reading resolvers are live 'icons' keys
pnpm lint0Tasks: 47 successful, 47 total (0 errors; warnings pre-existing)
pnpm --filter @object-ui/components run type-check0tsc --noEmit && tsc -p tsconfig.test.json — no diagnostics
pnpm type-check:scripts0tsc -p tsconfig.scripts.json — no diagnostics
pnpm exec vitest run packages/components/0Test Files 190 passed (190) / Tests 1731 passed (1731)
pnpm exec vitest run scripts/__tests__/check-lucide-icon-record-names.test.ts0Test Files 1 passed (1) / Tests 39 passed (39)
node scripts/check-changeset-presence.mjs0✅ 2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
node scripts/check-changeset-no-major.mjs0✅ No changeset declares a 'major' bump.
node scripts/check-changeset-fixed.mjs0✅ All workspace packages are in the changeset fixed group.
node scripts/check-vi-mock-specifiers.mjs0✅ check-vi-mock-specifiers: OK (… 685 relative specifier(s) resolved …)
node scripts/check-control-bytes.mjs0✅ check-control-bytes: OK (scanned 5226 tracked text file(s); skipped 85 binary).
node scripts/check-entry-guard.mjs --self-test0✓ check-entry-guard self-test: 63 cases pass
node scripts/check-entry-guard.mjs0✓ check:entry-guard: 47 scripts/ file(s) — no entry guard outside the baseline
node scripts/check-lint-coverage.mjs0✅ lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
node scripts/check-type-check-coverage.mjs0✅ type-check coverage: 45/46 via 'type-check' … 41/41 packages compile their tests
pnpm check:phantom-deps0✅ Every in-scope import is declared by the package that publishes it.
pnpm check:self-import0✅ No package names itself inside its own src/.

Two "ran but did not measure" traps ruled out by measurement, not by assumption:

  • The components type-check compiles tests through a second project
    (tsc -p tsconfig.test.json). --listFiles confirms both edited files are in that
    program — renderers/form/button.tsx and
    renderers/form/__tests__/button-shared-icon-resolver.test.tsx — so "type-check is clean"
    is a statement about the new test file too, not merely next to it.
  • pnpm lint is the repo-wide scan (eslint . in 47 tasks) and it was run in full, not
    narrowed: Tasks: 47 successful, 47 total in 4m6s. No narrowing needed and none claimed.

Left to CI, declared rather than skipped silently:check:esm-specifiers,
check:spec-symbols, check:action-forward-parity, check:designer-field-key-parity,
check:i18n-keys, check:i18n-drift, check:node-esm-load, check:published-dist,
type-check:vitest-setup, the repo-wide sharded pnpm test, the coverage merge, the CLI
self-check and Build & E2E. half-state-patrol is a scheduled backlog sweep, not a gate on
this diff.

8. Files

  • packages/components/src/renderers/form/button.tsx — imports the shared resolver; local
    toPascalCase, iconNameMap and the icons import deleted.
  • packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx
    new. 11 rows: 1 harness control, 2 routing (the discriminating pair), 8 behaviour.
  • scripts/check-lucide-icon-record-names.mjs — census: reader dropped, button type's
    resolver re-pointed.
  • scripts/__tests__/check-lucide-icon-record-names.test.ts — population pins 8 → 7.
  • .changeset/5993-button-shared-icon-resolver.mdpatch on @object-ui/components
    (node scripts/check-changeset-presence.mjs asked for one: published source changed).
    Never major, per AGENTS.md §版本号策略.

Generated by Claude Code

…lver
`renderers/form/button.tsx` carried a byte-equivalent reimplementation of
`renderers/action/resolve-icon.ts` — its own `toPascalCase`, its own
`iconNameMap` with the single `Home -> House` entry, its own index into
lucide's runtime `icons` record. Same algorithm, not the same function, so an
alias added to the shared resolver to absorb a lucide retirement reached every
`action:*` site and silently missed `ui:button` (objectui#5993).
Behaviour is unchanged, and measured rather than assumed: over 3547 names
(lucide's 1767 record keys in both spellings, plus kebab-case probes, the
`Home` alias, retired spellings and `undefined`) the two implementations agree
by object identity on 3539, differ on 8 only in the nullish flavour returned
for a miss (`undefined` from the record index vs `null` from the shared
resolver), and fork on none. `Icon` is consumed at exactly two truthiness
sites, so that difference cannot reach the DOM.
`scripts/check-lucide-icon-record-names.mjs` drops the file from
`DECLARED_RECORD_READERS` in the same commit: that census is rediscovered from
source on every run and fails on drift in both directions, which both verifies
the removal and makes a re-inlined copy fail the gate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3223.4 KB3266.6 KB
Main entry chunk (gzip)154.2 KB350 KB
Entry fileindex-Dfb38O55.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.96KB4.16KB
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.89KB114.59KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.89KB60.02KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)211.95KB42.75KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.92KB30.85KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.21KB54.43KB
plugin-kanban (index.js)52.83KB14.55KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.09KB6.62KB
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

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

Projects

None yet

2 participants

@os-support-ai@claude