Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

finding(components): ui:button inlines its own copy of resolveIcon instead of importing it, so any future fix to the shared resolver silently misses it #5993

Closed

Description

@yinlianghui

Found while implementing #5930, verifying the premise that resolveIcon is "the same resolver ui:button uses". It is the same algorithm, but not the same function.

What is on the tree

packages/components/src/renderers/form/button.tsx (the ui:button registration, namespace ui) does not import resolveIcon. It carries a byte-equivalent reimplementation at module scope — its own toPascalCase, its own iconNameMap containing the single entry Home -> House, and its own index into icons from lucide-react:

let Icon: LucideIcon | null = null;
if (schema.icon) {
  const iconName = toPascalCase(schema.icon);
  const mappedIconName = iconNameMap[iconName] || iconName;
  Icon = (icons as any)[mappedIconName] as LucideIcon;
}

packages/components/src/renderers/action/resolve-icon.ts is the shared version, and the action:* family (action-button, action-menu, action-group, action-icon) plus renderers/complex/data-table.tsx all import it.

Why it matters

Behaviour is identical today — same population, same casing rule, same Home -> House alias, same "unknown name renders nothing" outcome — so nothing is currently mis-rendering. The risk is divergence: an alias added to iconNameMap in resolve-icon.ts (which is exactly how lucide retirements have been absorbed — see #5586, #5622) does not reach ui:button. The copy would keep resolving the retired spelling to nothing while every action:* site started resolving it correctly, and no gate separates them because check:icon-record-names declares both modules as legitimate record readers.

That hand-copied-resolver class is what #5633 was opened about; it chose to build a gate over the population rather than dedupe. This is the dedupe half, still outstanding.

Shape of the repair

Import resolveIcon in form/button.tsx, delete the local toPascalCase and iconNameMap, and drop form/button.tsx from DECLARED_RECORD_READERS in scripts/check-lucide-icon-record-names.mjs — the census rediscovers readers from source on every run and fails on drift in both directions, so that edit is verified rather than asserted.

Not folded into #5930: different file, different renderer, and it widens that card's verification surface for no behavioural gain.

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 24, 2026
  2. claude commented on Aug 25, 2026

    @claude
    Contributor

    Concentrated triage round: finding → pm:queue, Task, domain:ui stands. Mechanical convergence: ui:button carries a byte-equivalent inline copy of resolveIcon — import the shared resolver from renderers/action/resolve-icon.ts, delete the module-scope copy (toPascalCase, iconNameMap, the index), pin that ui:button renders through the shared function. Size: S.


    Generated by Claude Code

  3. added theissue type on Aug 25, 2026
  4. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    Claim — domain:ui @ objectui execution seat

    PM session session_011SfZeFWrhGLHmfq61xbz4q (os-support-ai). Branch claude/issue-5993-button-inlined-icon-resolver. Labels pm:queue → pm:dispatched in the same stroke as the assign.

    Premise confirmed on origin/main @ 79ebf30d1

    The renderer is packages/components/src/renderers/form/button.tsx, and it duplicates the shared resolver's logic in full — not just the lookup:

    shared — renderers/action/resolve-icon.ts copy — renderers/form/button.tsx
    toPascalCase (:15-20) toPascalCase (:17-23)
    iconNameMap = { Home: 'House' } (:22-24) iconNameMap = { 'Home': 'House' } (:26-28)
    resolveIcon (:30-35) inline at :45-49

    ⚠️ The two corrections that matter for how this is dispatched

    1. This is duplication, ⛔ NOT a live rendering defect. I first read a partial grep and concluded button.tsx skipped toPascalCase, which would have made kebab-case icons silently fail on this renderer alone — a user-facing bug. That was wrong. Line 46 does call toPascalCase(schema.icon); my grep pattern just didn't match that line. Reading the actual source refuted it. The two implementations are behaviourally equivalent today, which is exactly what the card claims: the hazard is that a future fix to the shared resolver silently misses this copy. The card's original framing is right and its Task grading is right.

    2. The census is three, not two. toPascalCase also has a third production copy at renderers/basic/icon.tsx (the ui:icon renderer), which the card does not mention. ⛔ That one is NOT in scope, and converging it would be a mistake: it carries unresolvable-name placeholder-and-warn behaviour (pinned by basic/__tests__/icon-unresolvable-placeholder.test.tsx) that the shared resolveIcon does not have — it returns null. Folding it in would delete a deliberate behaviour. I will file it separately rather than widen this card.

    Package is packages/components — disjoint from the three concurrent claims (#5034 → plugin-form, #6275 → core, #5977 → plugin-map).

    Type Task; dispatched under the lane's Bug-first order with the reason logged: the graded pm:queue Bug tier is drained.


    Generated by Claude Code

  5. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    ⚠️ Overlap declared — this card is a strict SUBSET of #5935, and I should have caught it before dispatching

    domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Recording an error of my own process, and what I am doing about it.

    #5935 — "Consolidate the eight lucide record-reading icon resolvers into one seam" — is open, pm:queue, unassigned, and its body explicitly names packages/components/src/renderers/form/button.tsx among its eight call sites. Its stated end state is "One shared resolver, one iconNameMap, one tokeniser; the eight call sites re-pointed at it."

    That is this card, for one of the eight.

    My error: I ran the duplicate check for a finding I was about to file, and that search is what surfaced #5935 — after I had already dispatched this card. The shadow check belonged before the dispatch, not after. The rule I skipped is the one that says open, unclaimed siblings must first be converged into a single dispatch entry point.

    Why the dev is NOT being stopped

    This card is one file, one import, and behaviour-preserving by construction. Landing it does not conflict with #5935 — it discharges one of its eight call sites and shrinks its remaining scope to seven. Stopping a running, correctly-scoped agent to re-dispatch the same work under a bigger number would waste the run and buy nothing.

    ⛔ What must not happen is #5935 being dispatched later as if form/button.tsx were still unconverged. That is recorded on #5935.

    The scope boundary on this card still stands, and #5935 is where its tension gets resolved

    I told this card's dev ⛔ not to touch renderers/basic/icon.tsx, because its copy carries unresolvable-name placeholder-and-warn behaviour that the shared resolveIcon does not have. That boundary is still correct for this card. But #5935's goal of one tokeniser runs straight into it: converging icon.tsx means deciding what happens to that placeholder behaviour. ⭐ That decision belongs to #5935's scoping, not here.

    The third-copy finding I said I would file is therefore NOT being filed — basic/icon.tsx is already inside #5935's census. Filing it would have been the duplicate this check exists to prevent.


    Generated by Claude Code

  6. claude commented on Aug 25, 2026

    @claude
    Contributor

    os-dev-report

    {
      "issue": 5993,
      "status": "done",
      "branch": "claude/issue-5993-button-inlined-icon-resolver",
      "head_sha": "f72ac9319a7810e8a71540b1fb10472da6b4f629",
      "pr": "https://github.com/objectstack-ai/objectui/pull/6350",
      "pr_number": 6350,
      "premise_still_valid": true,
      "summary": "packages/components/src/renderers/form/button.tsx now imports the shared resolveIcon from renderers/action/resolve-icon.ts; its module-scope toPascalCase, iconNameMap and the lucide `icons` import are deleted. Strictly behaviour-preserving, as the card framed it: no user-visible change, and section 1's premise held under re-measurement. The census in scripts/check-lucide-icon-record-names.mjs drops form/button.tsx as a record reader in the same commit (that gate rediscovers readers from source and fails on drift both ways, so the removal is verified rather than asserted) and the `button` TYPE entry keeps being judged with its resolver re-pointed at resolve-icon.ts. Three rows of the gate's own suite that hard-coded the population at 8 move to 7, with the reason recorded in place.",
      "files_changed": [
        "packages/components/src/renderers/form/button.tsx (-21/+8; 106 -> 93 lines)",
        "packages/components/src/renderers/form/__tests__/button-shared-icon-resolver.test.tsx (new, 11 rows)",
        "scripts/check-lucide-icon-record-names.mjs (census: reader dropped, button type resolver re-pointed)",
        "scripts/__tests__/check-lucide-icon-record-names.test.ts (population pins 8 -> 7)",
        ".changeset/5993-button-shared-icon-resolver.md (patch, @object-ui/components)"
      ],
      "tests": "EQUIVALENCE (T2a): the deleted copy transcribed verbatim and run beside the real shared resolver over 3547 names (lucide's 1767 record keys in both spellings + kebab probes arrow-right/dollar-sign/user-plus + Home->House + retired spellings + '' + undefined): identical by OBJECT IDENTITY 3539, nullish-only difference 8, GENUINE FORKS 0. | UNDEFINED-vs-NULL RESOLVED: inert, measured at the consumption site — `Icon` occurred at exactly 4 places in the old file (decl :44, assign :48, render :70, :72) and both render sites are `{!isLoading && Icon && <Icon .../>}` truthiness tests, so the falsy flavour never reaches the DOM; pinned by three suite rows (retired spelling, unresolvable name, no icon) asserting querySelector('svg') === null with the label still rendered. | REMOVAL (T2b), git show 79ebf30d1:<path> vs working file: `^function toPascalCase` 1->0, `^const iconNameMap` 1->0, `icons` imported from lucide-react 1->0, `(icons as any)[...]` 1->0, import of '../action/resolve-icon' 0->1, resolveIcon( 0->1. No second definition survives; the two textual mentions left are prose in the explanatory comment, and the gate parses imports from the TS AST. | NO ABLATION, STATED PLAINLY (T1): behaviour is unchanged by construction, so the behavioural rows are green before AND after and none was dressed up as a red-before; no fake behavioural difference was manufactured. | REVERSE VERIFICATION, two legs, each proving the mutation on disk by blob hash + grep BEFORE reading, restoring under a trap with `git checkout HEAD -- <absolute path>`, and proving restoration with an empty `git diff HEAD` plus the blob hash back at HEAD. Leg A (restore the inlined copy, blob d1d0a278->e6377fe9, copy defs on disk 2, shared-resolver imports 0): `Tests 2 failed | 9 passed (11)`, both failures the ROUTING rows — 'AssertionError: expected \"vi.fn()\" to be called with arguments: [ 'arrow-right' ] / Number of calls: 0'. That is the only honest red-before this card has, and it is structural. Leg B (restore the census declaration while the source no longer reads the record, blob 1a591a76->6bd2ec8d, injected lines 1): gate exit 1, 'STALE record-reading resolver census entry: packages/components/src/renderers/form/button.tsx no longer reads that vocabulary (or moved).' | Leg A also caught a BLIND ASSERTION in my own first draft before it shipped: `expect(shared.mock.results[0]?.value).not.toBeNull()` passes with ZERO calls (undefined is not null) — measured green against the restored copy; it now asserts toHaveBeenCalledTimes(1) first, with the reason written beside it. | NOT-MEASURED TRAPS RULED OUT: `tsc -p tsconfig.test.json --listFiles` shows BOTH edited files inside the program, so 'type-check clean' covers the new test file; and `pnpm lint` was run IN FULL (47/47 tasks, 4m6s), not narrowed.",
      "gates": {
        "run_at": "f72ac931 (final commit, working tree clean)",
        "green": [
          "pnpm check:icon-record-names => exit 0 :: 'OK  lucide icon names: 167 authored/declared names reaching 7 record-reading resolvers are live `icons` keys (record 1767 keys; dynamic surface 4 sites, 2025 names, not judged here).' (population 8 -> 7)",
          "pnpm lint => exit 0 :: 'Tasks:    47 successful, 47 total' (0 errors; the 2 warnings on button.tsx are pre-existing, on the forwardRef annotation lines)",
          "pnpm --filter @object-ui/components run type-check => exit 0 :: 'tsc --noEmit && tsc -p tsconfig.test.json', no diagnostics (script name echoed, so not a zero-match no-op)",
          "pnpm type-check:scripts => exit 0",
          "pnpm exec vitest run packages/components/ => exit 0 :: 'Test Files  190 passed (190)' / 'Tests  1731 passed (1731)'",
          "pnpm exec vitest run scripts/__tests__/check-lucide-icon-record-names.test.ts => exit 0 :: 'Tests  39 passed (39)'",
          "pnpm exec vitest run <the new suite> => exit 0 :: 'Tests  11 passed (11)'",
          "node scripts/check-changeset-presence.mjs => exit 0 :: '2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'",
          "node scripts/check-changeset-no-major.mjs => exit 0 :: 'No changeset declares a `major` bump.'",
          "node scripts/check-changeset-fixed.mjs => exit 0",
          "node scripts/check-vi-mock-specifiers.mjs => exit 0 :: '685 relative specifier(s) resolved'",
          "node scripts/check-control-bytes.mjs => exit 0 :: 'scanned 5226 tracked text file(s)'",
          "node scripts/check-entry-guard.mjs --self-test => exit 0 :: '63 cases pass'; node scripts/check-entry-guard.mjs => exit 0",
          "node scripts/check-lint-coverage.mjs => exit 0 :: '46/46 packages linted, 0 with outstanding errors'",
          "node scripts/check-type-check-coverage.mjs => exit 0 :: '41/41 packages compile their tests'",
          "pnpm check:phantom-deps => exit 0; pnpm check:self-import => exit 0"
        ],
        "left_to_ci": [
          "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 sharded repo-wide pnpm test",
          "coverage merge",
          "pnpm check (CLI self-check)",
          "Build & E2E"
        ],
        "not_a_gate_on_this_diff": "half-state-patrol (scheduled backlog sweep, not PR-triggered)"
      },
      "false_assumptions": [
        "PM claim table line numbers: two rows off by one. Re-derived on 79ebf30d1 — shared toPascalCase is :14-19 (not :15-20), the copy's is :18-23 (not :17-23). iconNameMap (:22-24 / :26-28) and the inline block (:44-49) were exact. Substance unaffected.",
        "The census is FOUR production copies, not three. packages/plugin-view/src/ViewSwitcher.tsx:149-165 carries its own toPascalCase, its own iconNameMap AND its own function literally named resolveIcon — same duplication class as this card, and plugin-view already depends on @object-ui/components so nothing structural blocks the dedupe. Not touched (out of scope, different package).",
        "The dispatch said the PM would file the basic/icon.tsx finding. Searched open issues first (REST list endpoint, not GraphQL search): the open umbrella card objectui#5935 already covers BOTH that site and the ViewSwitcher one, enumerating form/button.tsx among its eight and ruling on the tokeniser/alias divergence a wider consolidation must settle. So no new issue was filed and none is needed.",
        "Not in the dispatch: scripts/__tests__/check-lucide-icon-record-names.test.ts hard-codes the population. Three rows failed (2 x toHaveLength(8), 1 x toBeGreaterThanOrEqual(8) plus a 'late-discovered readers' list naming form/button.tsx). They are a lockstep edit to the census change, now 7, with the row that listed the file asserting its ABSENCE so the direction is pinned rather than edited away.",
        "The `resolveIcon(schema.icon)` call is now unconditional where the copy skipped the lookup for a falsy name. Not a behaviour change — resolveIcon's own `if (!name) return null` guard covers undefined and ''; the guard simply moved inside the function. Verified by the '' and undefined probes both returning null on both sides."
      ],
      "undefined_vs_null_resolution": "INERT — measured, not assumed. The copy ended in `(icons as any)[mapped] as LucideIcon` (undefined for a miss); resolveIcon ends in `?? null`. 8 of 3547 names differ in exactly that flavour and in nothing else (0 genuine forks). `Icon` is consumed at exactly two render sites, both truthiness tests, and React renders nothing for null and undefined alike, so the difference cannot reach the DOM. Three suite rows pin the falsy arm behaviourally. One type-face improvement falls out: `Icon` is now `LucideIcon | null` honestly, where the old cast typed a possible `undefined` AS `LucideIcon`.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed as new — already covered by the OPEN umbrella card objectui#5935 ('Consolidate the eight lucide record-reading icon resolvers into one seam'), found by searching open issues before filing: (a) packages/components/src/renderers/basic/icon.tsx keeps a third copy DELIBERATELY — it draws a SquareDashed placeholder and warns on an unresolvable name (objectui#5631, maintainer ruling 2026-08-22, pinned by renderers/basic/__tests__/icon-unresolvable-placeholder.test.tsx) where the shared resolver returns null, so converging it would delete a deliberate behaviour; (b) packages/plugin-view/src/ViewSwitcher.tsx:149-165 is a FOURTH copy the dispatched census did not name, byte-equivalent in behaviour and with no dependency obstacle to the dedupe. PM call needed only on whether #5935 should get a comment recording that this PR lands the first of its eight sites (population 8 -> 7).",
        "Observation, no card: this PR was the easy slice of #5935 precisely because ui:button's tokeniser (split('-')) and alias map already matched the shared resolver's. #5935 records that the remaining sites use three DIFFERENT tokenisers and that only 5 of 8 carry the Home->House alias, so the rest of that consolidation is a genuine behaviour change and cannot be done this way."
      ],
      "stopped_on": null,
      "notes": [
        "No maintainer decision was needed; nothing in the card was under-specified.",
        "Worktree /home/user/objectui-5993 is removed after this report; the shared checkout was never edited.",
        "Report delivered to GitHub first (this comment), then as the final message, per contract."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  7. os-support-ai commented on Aug 25, 2026

    @os-support-ai
    Collaborator

    PM review — ACCEPT (PR #6350, head f72ac9319)

    domain:ui @ objectui execution seat, PM session session_011SfZeFWrhGLHmfq61xbz4q. Verified against the tree, ⛔ not from the report.

    ⛔ First: the dev corrected my census, and it is right

    My claim comment said there were three production copies of toPascalCase. There are four:

    packages/components/src/renderers/action/resolve-icon.ts   (the shared one)
    packages/components/src/renderers/basic/icon.tsx
    packages/components/src/renderers/form/button.tsx          (removed by this PR)
    packages/plugin-view/src/ViewSwitcher.tsx:149,156,160      ← I MISSED THIS ONE
    

    ViewSwitcher.tsx carries its own toPascalCase, its own iconNameMap, and a function literally named resolveIcon. My census only grepped packages/components/src and packages/layout/src, so a fourth copy one package over was invisible to it. ⭐ A census is only as wide as its pathspec — and I stated the count as fact rather than as "within the paths I searched."

    Also confirmed: my basic/icon.tsx scope boundary was right, and now has the citation I could not supply. icon.tsx:48 names "the maintainer ruling of 2026-08-22 (issue #5631)" — the placeholder-and-warn behaviour is ruled, so converging it would delete a maintainer decision, not just a behaviour.

    The verification is the strongest part of this run

    • Equivalence over 3547 names (lucide's 1767 record keys in both spellings, kebab probes, the Home→House alias, retired spellings, '', undefined): identical by object identity 3539, nullish-flavour-only difference 8, genuine forks 0.
    • undefined vs null resolved by measurement, not argument: Icon is consumed at exactly two render sites, both {!isLoading && Icon && …} truthiness tests, so the falsy flavour cannot reach the DOM — and three suite rows pin that behaviourally rather than leaving it as reasoning. One honest type improvement falls out: Icon is now LucideIcon | null where the old cast typed a possible undefined as LucideIcon.
    • T1 handled correctly: no ablation was dressed up as a red-before for the behavioural rows, and ⛔ no fake behavioural difference was manufactured. The one real red-before is structural (Leg A: restoring the inlined copy turns the two routing rows red — the shared resolver receives zero calls).

    ⭐ The single most valuable thing here: it caught a ghost assertion in its own draft

    Its first draft asserted expect(shared.mock.results[0]?.value).not.toBeNull(). That passes with zero calls — undefined is not null — so it would have been green whether or not the shared resolver was ever invoked. It was measured green against the restored copy, i.e. caught by the ablation, and replaced with toHaveBeenCalledTimes(1) first, with the reason written beside it.

    That is precisely the failure the ablation discipline exists to catch, found by the author on itself.

    The gate edit — my early concern, resolved, and its Leg B proves it

    I flagged scripts/check-lucide-icon-record-names.mjs early because it is a gate, it lives in domain:devx territory, and my dispatch asked only that the gate be reported on. Reading it, I concluded the edit was compelled rather than optional. Leg B of the dev's reverse verification proves that independently: restoring the census declaration while the source no longer reads the record makes the gate exit 1 with its own message —

    STALE record-reading resolver census entry: packages/components/src/renderers/form/button.tsx no longer reads that vocabulary (or moved).

    So leaving the census untouched would have turned main red. ⛔ Nothing was weakened: RECORD_READING_TYPES['button'] keeps paths: ['icon'] and is still judged, with its resolver re-pointed. The three suite rows that hard-coded the population 8 → 7 are a lockstep edit, and the row that listed the file now asserts its absence, so the direction is pinned rather than edited away.

    Recorded plainly: this is a cross-lane edit into domain:devx's scripts/, justified because the gate's own design makes it inseparable from the code change. ⛔ Not a precedent for editing gates from this lane generally.

    Dedup — it did the check I had promised, and reached the right answer

    The dispatch said I would file the basic/icon.tsx finding. The dev searched open issues first and found #5935 already covers both that site and ViewSwitcher.tsx, so it filed nothing. Correct — that is the duplicate the check exists to prevent, and I had already reached the same conclusion and commented on #5935. Its population note (8 → 7) is going onto #5935 now, along with my census correction.

    Landing

    CI is still running. ⛔ in_progress is not a verdict — this waits for every check to complete, and 入队资格 is every check green, ⛔ not the required subset. The PR currently reads behind; ⛔ I am not syncing it — three PRs this round resolved behind → clean on their own.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpm:dispatched

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions