Skip to content

feat(types,components)!: ui:icon names its glyph with icon, not the identity key name - #6012

Merged
yinlianghui merged 4 commits into
mainfrom
claude/issue-5631-ui-icon-contract-migration
Aug 24, 2026
Merged

feat(types,components)!: ui:icon names its glyph with icon, not the identity key name#6012
yinlianghui merged 4 commits into
mainfrom
claude/issue-5631-ui-icon-contract-migration

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#5631

The chartered contract migration, per the maintainer ruling of 2026-08-24 — 「5631 A′,按一次正经的契约迁移立项。」 — which confirms the 2026-08-22 direction (A: icon is the icon key, name is identity always) at the price the escalation measured. All four ruled parts move in one stroke: contract, corpus, renderer, docs + registry.

Final verification ran on cc4d700a (this branch's head, after merging origin/main).

The defect

name is the SDUI identity key every authored node carries alongside id — not ui:icon's private prop. So an ordinary node like { type: 'icon', id: 'save_icon', name: 'save_icon' } asked lucide for SaveIcon, missed, and rendered nothing: silent to a human, and clean to a DOM gate, because a renderer that renders nothing spreads no attributes to find.

Zone 2 re-verified before building on it

Both prior measurements were re-run on this branch's base (post-#5959), with control probes.

The contract, measured against the built zod mirror — probe output, before the change:

REJECT RULED SHAPE { type:'icon', icon:'check' }
-> invalid_type at [name]: expected string, received undefined
ACCEPT LEGACY SHAPE { type:'icon', name:'check' }
ACCEPT CONTROL both { type:'icon', icon:'check', name:'save_icon' }
REJECT CONTROL empty { type:'icon' } -> invalid_type at [name]
REJECT CONTROL wrong { type:'icon', icon: 42 } -> invalid_type at [name]

Reproduced exactly. The last control is the sharpest: icon: 42 was refused at [name], i.e. icon was not a declared key at all — the published contract required the broken shape and did not know the ruled one. That is why the renderer could not be migrated alone.

After the change, same probe:

ACCEPT RULED SHAPE { type:'icon', icon:'check' }
REJECT LEGACY SHAPE { type:'icon', name:'check' }
-> invalid_type at [icon]: ui:icon names its glyph with `icon` (e.g. `icon: 'check'`). If this
node still names it with `name`, that key moved: `name` is the SDUI identity key on every node
and is no longer read as a glyph name (objectui#5631). Rename `name` to `icon`, or convert
stored metadata in bulk with `migrateIconNodeKeys` from `@object-ui/types`.
ACCEPT CONTROL both { type:'icon', icon:'check', name:'save_icon' }
REJECT CONTROL empty { type:'icon' }
REJECT CONTROL wrong { type:'icon', icon: 42 } -> invalid_type at [icon]: expected string, received number

The last control matters twice over: it shows the migration message is scoped to the absent case only, so a genuine type error still gets zod's own precise diagnosis rather than a wrong diagnosis about a rename.

The corpus sweep re-run with a brace-matching scanner over all tracked .ts/.tsx/.json/.mdx/.md:

count
icon nodes naming the glyph with name98
icon nodes already using icon0
of those 98, name resolves to a live lucide glyph (mechanical rename)95
of those 98, name does not resolve3

98 / 0 confirmed exactly, matching #5959's reading. (My scanner initially reported 101; the extra three are the IconSchema declaration in layout.ts, the interface block in icon.mdx, and one doc example in content/docs/blocks/block-schema.mdx — declarations and prose, not authored nodes. The doc example is converted too.)

The 3 non-resolving ones are all icon: 'check-circle' in examples/schema-catalog/src/schemas/marketing/call-to-action.json. check-circle is a retired lucide spelling (#5622 / #5586 family — the wrong name, not the wrong key). They rendered nothing before this PR and render the visible placeholder after it: behaviour unchanged, no regression, and deliberately not "fixed" here. Filed as #6009 — see below.

So the ruling's stop-and-report clause did not fire: every node was mechanically convertible as a key rename, which is what this card is about.

What changed

1. Contractpackages/types/src/layout.ts + zod/layout.zod.ts

IconSchema declares icon: stringrequired, exactly as name was required before it — a key rename at constant strictness, not a loosening. name reverts to the optional identity inherited from BaseSchema.

Keeping requiredness constant is also what keeps __tests__/zod-mirror-parity.test.ts's ledger silent: an optional mirror key against a required declaration is drift that guard measures and would demand a KnownDrift entry for. The parity suite is green with no ledger edit.

2. Corpus — 98 nodes converted by codemod across 22 files

Mechanical, offset-spliced, quoting style preserved. The codemod's own dry run reported 97 visited / 97 converted (the 96 non-test nodes + the block-schema.mdx example); the two nodes in basic-renderers.test.tsx were excluded from the sweep and triaged by hand — see below. Post-migration sweep: zero authored name-as-glyph nodes remain anywhere; every residual hit is prose documenting the legacy shape or a deliberate legacy-shape fixture.

3. Rendererrenderers/basic/icon.tsx reads schema.icon

⛔ No icon ?? name fallback. schema.name is read at exactly one place — to make the migration diagnostic specific — and is never a glyph source: a node carrying it still renders the placeholder. PR #5959's placeholder behaviour is otherwise untouched.

4. Docs + registry

The registry inputs entry now declares icon, and #5959's comment explaining why it deliberately still said name is retired, as the ruling directs. content/docs/components/basic/icon.mdx documents the key, the identity distinction, and the migration.

The break is loud in three places, never silent

  1. The contract refuses a legacy node with the message quoted above — naming the rename and the converter, not zod's default expected string, received undefined.
  2. The renderer draws fix(components): render a visible placeholder when a ui:icon glyph does not resolve #5959's visible placeholder and its console.warn now carries the exact rename (icon: "save_icon") plus the converter's name; the accessible name says so too, and the host gains data-objectui-icon-legacy-name-key so a gate can tell "unmigrated node" from "glyph that does not resolve".
  3. migrateIconNodeKeys (new, @object-ui/types) converts stored tenant metadata in bulk. Deployed metadata is unmeasured and the prior is high reliance, so the conversion is shipped rather than assumed.

On that converter — it is a one-shot conversion, not a read-path fallback, and the distinction is the ruling's. Nothing calls it during rendering or parsing; it is zod-free, lives on the main entry beside the dashboard-filter-alias.ts precedent, and is run by a deployer over stored documents. It reports rather than guesses on the two cases it will not touch: a node already declaring both keys (icon wins, name stays the identity it is — overwriting would replace a working glyph with an identity string), and a node naming no glyph at all.

Fixture triage — three dispositions, not one spelling sweep

  • Spelling — the resolvable cases in icon-unresolvable-placeholder.test.tsx and both cases in basic-renderers.test.tsx.
  • Replaced entirely — the "warns, naming the identity-key collision" case pinned the branch being removed. Rewritten, and a new describe block pins the legacy shape as non-resolving, using name: 'check' deliberately: check is a real glyph, so a tolerant fallback would resolve it and the case would go green. A nonsense name could not tell a fallback from a miss.
  • Given teethbasic-renderers.test.tsx asserted only "some svg rendered", which the placeholder satisfies too: an assertion that survives this migration by being unable to fail. Both cases now assert the absence of the placeholder marker.
  • Found by running, not by greppingicon-renderer-declared-default.test.ts selects the registry input by key, so the rename left it selecting undefined. Selector updated; its Two more retired lucide spellings reach the icons-record resolver — edit in DetailView's mobile Edit action, smile as the icon renderer's own default — and only one of the four resolver copies is pinned #5622 subject and controls untouched.

Reverse verification

Predicted direction: red, because the new declaration makes icon required. Pasting the retired spelling into a consumer package (packages/components), against the rebuilt .d.ts:

=== typecheck WITH the retired spelling (expect RED) ===
EXIT=1
__reverse-5631-probe.ts(3,14): error TS2741: Property 'icon' is missing in type
'{ type: "icon"; name: string; }' but required in type 'IconSchema'.
=== typecheck WITHOUT it (expect GREEN) ===
EXIT=0

Run from a committed state, with a trap … EXIT INT TERM restoring the probe; the tree was verified byte-identical to HEAD afterwards. This also proves the consumer read the rebuilt declaration rather than a cached one.

Sequencing — the BARE_SPREAD_ON_SVG row is untouched

Per the fence, #5632's burn-down keeps excluding ui:icon's ledger row and this PR does not "fix" it. It is unchanged at 14 attributes including name, and the sweep is green with no ledger edit: the renderer still spreads the authored identity onto the SVG host, and that is #5632's to close. The new data-objectui-icon-legacy-name-key marker cannot perturb the row — data- is an OPEN_PREFIXES entry in packages/test-support/src/dom-leak-judge.ts, so it is allowlisted by construction, and the sweep confirms it.

Verification

All on cc4d700a, after merging origin/main (which brought in #6003; packages/types/src/index.ts auto-merged cleanly).

vitest run packages/types/ + the four icon suites + widget-dom-leak-sweep + examples/schema-catalog
-> Test Files 71 passed (71) | Tests 2502 passed (2502)
pnpm --filter @object-ui/types --filter @object-ui/components run type-check -> EXIT=0
pnpm --filter @object-ui/types --filter @object-ui/components run lint -> EXIT=0 (0 errors)
pnpm --filter @object-ui/site run lint -> EXIT=0 (0 errors)

Repo gates re-derived from package.json + .github/workflows/ (this repo has no dispatch-gates.mjs) and re-run on the final head — each line is the gate's own verdict, not $? after a pipe:

check-changeset-presence EXIT=0 9 source file(s) of 2 released package(s) changed, 1 changeset(s)
check-changeset-no-major EXIT=0 No changeset declares a `major` bump.
check-control-bytes EXIT=0 OK (scanned 4975 tracked text file(s))
check-lucide-icon-record-names EXIT=0 64 authored/declared names ... are live `icons` keys
check-doc-component-types EXIT=0 Every documented component type is registered.
check-doc-snippet-types EXIT=0 Semantic phase: 148 of 148 block(s) judged, 0 failed.
check-doc-links EXIT=0 Links are valid across 13 scan roots.
check-type-check-coverage EXIT=0 41/41 packages compile their tests, 0 declared debt
check-lint-coverage EXIT=0 46/46 packages linted, 0 with outstanding errors
check-package-self-import EXIT=0 No package names itself inside its own src/.

Earlier full runs on the pre-merge tree: vitest run packages/components/ — 183 files, 1681 tests (the one failure was icon-renderer-declared-default.test.ts, fixed in f4bd018); vitest run examples/schema-catalog/ packages/react/… packages/layout/ — 31 files, 1859 tests, green.

Scope narrowing, declared: repo-wide turbo run lint and the full vitest run were left to CI; local runs were filtered to the affected packages plus every suite the post-migration sweep proved could contain an icon node. The sweep is what bounds that narrowing — after conversion, zero icon nodes exist outside packages/types, packages/components, examples/schema-catalog, apps/site and the docs, all of which were run.

Out of scope, filed not fixed

#6009ui:icon becomes a record-reading resolver here, but its type is absent from check-lucide-icon-record-names.mjs's RECORD_READING_TYPES census, so 96 authored glyph names go unjudged. Measured from the gate's own --report counter, before and after the conversion on the same tree (reverted under a restoring trap, tree verified byte-identical to HEAD after):

before: authored icon names judged: 30 | declines to judge: 254
after: authored icon names judged: 30 | declines to judge: 350

+96 declined, judged unmoved — exactly the 96 non-.mdx converted nodes, which moved from "invisible to the gate" to "seen, and declined". The gate stays green either way. Adding the census entry would turn it red on the three check-circle nodes, which would drag #5622's retired-spelling repair into this PR — hence filed, unassigned, not folded in.

Not folded in

This card was the wrong schema key. A fix for any one of those leaves the others intact.


Generated by Claude Code

…he identity key `name`
`name` is the SDUI identity key every authored node carries alongside `id`, not
`ui:icon`'s private prop. An ordinary `{ type:'icon', id:'save_icon',
name:'save_icon' }` therefore asked lucide for `SaveIcon`, missed, and rendered
nothing — silent to a human and clean to a DOM gate, because a renderer that
renders nothing spreads no attributes to find.
This is the chartered contract migration, not a renderer patch. `IconSchema`
and its zod mirror declare `icon: string` required, exactly as `name` was
required before it, and `name` reverts to the optional identity inherited from
`BaseSchema`. The mirror's previous `name: z.string()` requirement is what
blocked the renderer-only fix: the published contract refused the ruled shape
while the renderer read a key the contract never declared.
There is deliberately no `icon ?? name` fallback. The break is loud in three
places instead: the mirror refuses a legacy node with a message naming the
rename, the renderer draws the existing placeholder and warns with the exact
rename plus a `data-objectui-icon-legacy-name-key` marker, and
`migrateIconNodeKeys` converts stored metadata in bulk, reporting rather than
guessing on nodes carrying both keys or no glyph key at all.
All 98 authored icon nodes in the repo are converted, along with the registry
`inputs` entry and the component docs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
…default pin
`icon-renderer-declared-default.test.ts` selects the registry input that drives
a dropped component's default by key. That key moved from `name` to `icon`, so
the selector came back `undefined` and the pin lost its subject.
Only the selector and the prose naming it moved; the file's subject is still
objectui#5622's retired-spelling question, and its controls are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
…lt types
`check-doc-snippet-types.mjs` type-checks documentation code blocks, and the
`migrateIconNodeKeys` example referenced two undeclared names. Declared them so
the snippet is judged rather than excluded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3233.0 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-DZqPozUt.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.23KB114.56KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)165.30KB45.79KB
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.44KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.88KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.10KB6.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)8.50KB2.88KB
plugin-view (index.js)84.57KB20.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)52.40KB17.45KB
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 24, 2026 12:57
@yinlianghui
yinlianghui added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit d2fb6efAug 24, 2026
23 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-5631-ui-icon-contract-migration branch August 24, 2026 13:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui:icon reads the SDUI identity key name as its lucide icon name, so any icon node that authors name renders nothing at all

2 participants

@yinlianghui@claude