Skip to content

fix(components): render a visible placeholder when a ui:icon glyph does not resolve - #5959

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-5631-ui-icon-icon-key
Aug 24, 2026
Merged

fix(components): render a visible placeholder when a ui:icon glyph does not resolve#5959
os-warren merged 1 commit into
mainfrom
claude/issue-5631-ui-icon-icon-key

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #5631

⚠️Deliberately Part of, not a closing keyword. This PR lands items 3 and 4 of the 2026-08-22 ruling. Item 1 — ui:icon reading schema.icon — is NOT here, blocked on the metadata sweep below, which is the ruling's own stop condition. #5631 must stay open when this merges.

The sweep the ruling required, and its reading

The ruling put a sweep of real authored metadata first, and made a population that cannot be migrated cleanly a stop-and-report. It ran first. It stops the key change.

What was scanned

CorpusMethodIcon nodesAuthor nameAuthor icon
examples/schema-catalog/src/schemas/**every .json parsed and walked as a tree, matching type: 'icon' / 'ui:icon' at any depth71 (25 files)710
apps/site/app/playground/page.tsxauthored node literals25250
packages/components/src/__tests__/basic-renderers.test.tsxauthored node literals220
content/docs/**icon.mdx, block-schema.mdxdocumented shapedeclares name
objectstack (sibling repo, working checkout)same grep over .json/.ts/.tsx/.mdx/.yml0

Reading: 98 authored icon nodes, 100% of them naming the glyph with name, and zero already on icon. Not a legacy tail — the entire corpus.

Also measured: no code path synthesizes icon nodes. No bridge, defaults map or palette builds { type: 'icon' } at runtime, so the whole population is hand-authored metadata and there is no producer to fix at the producer.

What the sweep could NOT see — stated, not rounded to zero

  • Deployed tenant metadata is not measurable from here. Not "zero" — unmeasured. Given 98/98 in-repo reliance and that name is what every doc and the type itself advertises, the prior for deployed content is high reliance, not low.
  • The cloud repo is not checked out in this container. Not scanned.
  • The objectstack reading is from its working checkout at 4c9780c, not a clean main.

The contract blocks item 1 independently of the corpus

The published type is not merely silent about icon — it rejects the ruling's authoring shape. Measured against the built @object-ui/types zod mirror:

REJECT { type:'icon', icon:'check' } -> invalid_type at [name]: expected string, received undefined
ACCEPT { type:'icon', name:'check' }

IconSchema declares name: z.string()required, documented verbatim as "Icon name (lucide-react icon name)". So under ruling A an author writing { type: 'icon', icon: 'save' } is refused by the contract while the renderer reads a key the contract does not declare — contract-first exactly backwards (AGENTS.md #0 / #0.1).

Item 1 therefore cannot land at the renderer alone. It needs, in one change: packages/types/src/layout.ts + packages/types/src/zod/layout.zod.ts (declare icon, demote name to identity), the 98-node corpus conversion, content/docs/components/basic/icon.mdx, and the renderer's registry inputs entry. That is ~29 files across four workspace entries including the protocol package — far outside this card's declared file surface, and a published-contract change in its own right.

Per the ruling: stopping and reporting rather than shipping a break I cannot account for.

What this PR does land

1. An unresolvable icon renders a visible placeholder (ruling item 3, unconditional)

The ruling makes this hold "regardless of the key question", and triage recorded it as the half that need not wait on adjudication. return null is gone.

It failed silently in two independent ways at once, which is why it survived so long:

  • Invisible to a human — no gap, no error boundary, just an absent glyph the eye completes.
  • Invisible to a gate — a renderer returning null spreads no attributes, so a DOM scan of it reports no findings. An empty scan and a clean scan are the same reading.

Now: a dashed-square placeholder on the same SVG host, keeping the authored className, size and colour so the gap sits exactly where the icon would have been, plus role="img", an accessible name naming the icon that failed, and a data-objectui-icon-unresolved marker for gates. The console.warn stays and now names the cause.

Also fixed, found while implementing: a node with no name at all reached toPascalCase(undefined), threw on undefined.split, and had the throw swallowed by SchemaErrorBoundary — a third silent failure on this renderer. It renders the placeholder too.

The placeholder glyph is a direct named import, not an icons-record lookup. A placeholder that itself fails to resolve is the original bug one level up, and silent in the same way. Lucide retires a spelling by dropping it from the runtime record while keeping the deprecated named export — the #5622 mechanism. Measured on lucide-react 1.31.0: CircleHelp and HelpCircle, the two obvious "unknown" glyphs, are both absent from the record while both still resolve as named exports. Either one, looked up the usual way, would have rendered nothing. A named import is resolved at build time, so a future retirement fails the build loudly instead of silently returning to null. Pinned by a test.

2. The BARE_SPREAD_ON_SVG ledger row, re-measured (ruling item 4)

The sweep entry for ui:icon carried a workaround — schemaExtras: { name: 'check' } — forcing a resolvable name because the renderer otherwise rendered nothing. With the placeholder, that workaround is no longer needed, so ui:icon is now swept as an ordinary plain target on the node the sweep actually authors: identity name: 'canary_node' and nothing else.

Re-measured on that node, the row is UNCHANGED — the placeholder is the same bare spread onto the same SVG host, so it leaks the same fourteen attributes. That the row did not move is the result: the reading no longer depends on a workaround that concealed whether the renderer rendered at all.

name stays in that row. The renderer still spreads the authored identity onto the SVG, and closing that is the #5632 burn-down — deliberately not folded in. Per the ruling's sequencing note, this lands before that batch (or that batch excludes this row); nothing here touches #5632's scope.

Verification

All at final commit c474bb04b.

CheckResult
vitest run packages/components/Test Files 182 passed (182) / Tests 1669 passed (1669)
vitest run …/widget-dom-leak-sweep.test.tsx + new icon testTest Files 2 passed (2) / Tests 209 passed (209)
type-check (@object-ui/components, @object-ui/app-shell)exit 0, both tsc --noEmit && tsc -p tsconfig.test.json echoed
lint (same two packages)0 errors; 906 + 2642 pre-existing warnings — icon.tsx at 3 warnings, exact parity with origin/main
check:icon-record-namesOK lucide icon names: 64 authored/declared names … are live icons keys
check:control-bytes✅ OK (scanned 4941 tracked text file(s))
check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
check:action-forward-parityexit 0
check-changeset-presence.mjs✅ 2 source file(s) … declares 1 changeset(s)
check-changeset-no-major.mjs✅ No changeset declares a major bump.

Reverse verification

Direction predicted before running: RED in both. The fix was committed first, so the mutation had a real restore point; the mutation script carried a trap … EXIT INT TERM restore, and both legs were confirmed on disk by grepping the anchored text in both directions (injected present / removed absent) rather than trusting an editor exit code.

Restoring return null; in the placeholder branch:

  • new icon test → Tests 4 failed | 4 passed (8) — the four unresolvable cases fail; the four resolvable / named-import cases stay green, which is correct since they do not exercise that branch.
  • leak sweep → Tests 1 failed | 200 passed (201), and the one failure is ui:icon. That is what proves removing the schemaExtras workaround is load-bearing rather than cosmetic: without the placeholder, ui:icon cannot be swept on an ordinary authored node at all.

Restore leg: marker absent, placeholder present, git status clean, both suites back to 209 passed.

The sweep's stderr under mutation also shows the card's defect out loud for the first time:

ui:icon: no lucide glyph resolves for "canary_node" (lookup: "Canary_node"). Rendering a
visible placeholder instead of nothing (objectui#5631). Note: this renderer reads the SDUI
identity key `name` as its glyph name, so an ordinary authored identity such as "save_icon"
lands here.

Warning assertions use an explicit vi.spyOn. Vitest 4 runs silent: 'passed-only', so console.warn from a passing test is discarded — measured here directly: a debug probe printed nothing until its assertion was forced to fail. A test "checking" the warning by reading output would check nothing.

On the declared inputs list — checked, deliberately unchanged

The registry declares { name: 'name', label: 'Icon Name', defaultValue: 'face-slightly-smiling' }, so the renderer's declared authoring surface does itself advertise name as the glyph key. Under ruling A it is owed a rename to icon. It is not renamed here: the resolver still reads name, and a declared input list advertising a key the resolver does not read is this same defect pointing the other way. The two must move together with the corpus migration. An in-file comment records this so it is not lost.

Not folded in

Each is a different mechanism; none is closed by this PR.


Generated by Claude Code

…es not resolve
An unresolvable `ui:icon` used to `return null`, which failed silently in two
independent ways at once: invisible to a human, and clean-looking to a gate,
because a renderer that returns `null` spreads no attributes and an empty DOM
scan reports no findings.
It now renders a dashed-square placeholder on the same SVG host, keeping the
authored className/size/colour, with role="img", an accessible name that says
which icon failed, and a data-objectui-icon-unresolved marker. A node with no
`name` at all previously threw in toPascalCase(undefined) and had the throw
swallowed by the error boundary; it renders the placeholder too.
The placeholder glyph is a direct named import rather than a lookup in
lucide's runtime `icons` record: lucide retires a spelling by dropping it from
that record while keeping the deprecated named export, so a record-based
placeholder could itself resolve to nothing. Measured on lucide-react 1.31.0,
both CircleHelp and HelpCircle are absent from the record.
The widget-dom-leak-sweep entry for ui:icon no longer needs its forced
`schemaExtras: { name: 'check' }` workaround, so the renderer is swept on the
ordinary canary node. Its BARE_SPREAD_ON_SVG row was re-measured on that node
and is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ZyKZejBWZoCSj1NP35wcp
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3232.3 KB3990.2 KB
Main entry chunk (gzip)153.6 KB350 KB
Entry fileindex-D6Jhk1XT.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.13KB3.77KB
app-shell (runtime-config.js)13.57KB4.78KB
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)504.75KB114.32KB
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.00KB61.86KB
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.06KB6.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)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)3.77KB1.33KB
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)0.20KB0.18KB
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 (index.js)3.88KB1.85KB
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

Development

Successfully merging this pull request may close these issues.

2 participants

@os-warren@claude