Skip to content

fix(components): resolve the declared action type for a bar-hosted action:icon - #6362

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-6306-action-icon-type-resolution
Aug 25, 2026
Merged

fix(components): resolve the declared action type for a bar-hosted action:icon#6362
os-support-ai merged 1 commit into
mainfrom
claude/issue-6306-action-icon-type-resolution

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #6306

An action:icon rendered as a member of an action:bar forwarded the component id as the action type, so the click resolved no handler: nothing happened, with no error and no toast — the objectstack#2169 "Mark Done does nothing" shape.

All gate readings below were taken on the final commit of this branch, afb35717b.

Premise, re-derived on origin/main @ 194fae184

Not taken on trust from the card. Every line number in the report reproduces exactly on the ref this branch is cut from:

action-bar.tsx:309       actionType: action.type,                  <- the real action type is composed
action-button.tsx:148    type: schema.actionType || schema.type,   <- resolved
action-icon.tsx:99       type: schema.type,                        <- NOT resolved; actionType dropped
ActionRunner.ts:954      const actionType = action.type || action.actionType || action.name || '';

action:bar does not route members through SchemaRenderer. It pulls each member's renderer off the registry and renames the declared type as it spreads it onto the child: type becomes the component id, and the real declaration moves to actionType. action:button resolves that pair when it forwards; action:icon read schema.type alone and dropped actionType entirely, handing the runner type: 'action:icon'.

'action:icon' binds no registered handler and no builtin. For a declaration carrying target rather than endpoint it does not reach the legacy navigate/api fallback either, so it falls through to executeActionSchema and the authored action never runs. The bug was a function of the layout, not the declaration — one authored action executed or did nothing depending only on which component the host picked for it.

Census (a control, not an assumption)

type: schema appears in exactly two files under renderers/action/: action-button.tsx (already correct) and action-icon.tsx. action:group (:249) and action:menu (:220) forward type: action.type from their own member array — the member's declared type is used directly and never renamed, which is why the construct does not appear there. They are a genuinely different composition, not a third instance. No third case was found; nothing else was touched.

The fix

action-icon.tsx gets the same resolution action-button.tsx already had. actionType joins the props intersection for the same reason it sits on action:button's — it is the host-composed override read first, and it is not a UIActionSchema key. No accept set is widened; this restores declared behaviour only.

The || schema.type leg is load-bearing in the other direction: rendered standalone, action:icon never sees an actionType, and its own registry inputs declare type as the action type.

The pin executes clicks, and carries a positive control

The reason this shipped is that the existing icon coverage (action-bar-member-visible-gate.test.tsx:195-213) renders action:icon bar members three times and asserts only visible/enabled — it never asserts that a click reaches a handler. The new pin asserts execution, not props.

Positive control. Every row that reads the icon member's zero renders a sibling action:button member of the same declaration in the same bar, with an api handler registered through ActionProvider, and reads its one first. This is what makes a zero a reading rather than a dead probe: a harness that executes nothing at all — an unmounted renderer, a member pushed into the overflow menu, an assertion racing the async execute — reports zero on both members, so the control tells the two apart from the failure message alone. The two members differ in exactly one authored key, component; name/label differ only as addressing handles, and the runner never consults them here (type is always truthy on this path, so its || action.name leg is unreachable).

Two further design points:

  • A trap handler keyed on the component id (handlers: { api, 'action:icon': trap }) makes the defect two-sided rather than merely absent — the unfixed renderer produces a positive artefact instead of only a missing call. Measured on the unfixed tree: expected "vi.fn()" to not be called at all, but actually been called 1 times. That is direct evidence the component id reached the runner as the action type, and the row fails in 22 ms instead of timing out at ~1000 ms.
  • Both ceilings are pinned (maxVisible: 10 and mobileMaxVisible: 10). The inline/overflow split reads mobileMaxVisible ?? 1 when useIsMobile() is true, so pinning only maxVisible would leave the split at the mercy of the environment's viewport — a zero that is not about type resolution at all.
  • The standalone row is green in both worlds on purpose. It refuses a "fix" written as schema.actionType alone, which would trade this defect for its mirror image on the surface where type is the action type.

Ablation — predicted before running, then compared

Prediction was recorded before the first run: 5 rows, 3 red / 2 green before the fix, 5 green after, with the zero caused by fall-through to executeActionSchema (silent, no throw).

row predicted actual (unfixed tree)
positive control — action:button member reaches the handler green green
the action:icon member reaches the same handler red redexpected "vi.fn()" to be called 2 times, but got 1 times
the runner is handed the declared type, not the component id red red
both members of one declaration resolve to the same type red redexpected "vi.fn()" to be called 2 times, but got 1 times
regression guard — standalone action:icon green green

Observed: Tests 3 failed | 2 passed (5) — rows and counts exactly as predicted. After the fix: Tests 5 passed (5).

Where the prediction was wrong. In the trap row the api waitFor was written first, so it timed out and the trap's own assertion never evaluated — the "two-sided" half was asserted rather than measured. The row was restructured to settle on either path (api.mock.calls.length + trap.mock.calls.length) and assert the trap first, and the ablation was re-run: the trap then fired observably (quoted above). The two-sided claim above is measured, not inferred.

check:action-forward-parity is blind to this defect — and its green run is not evidence

type is in the forward whitelist, and the gate diffs key presence against the owed set. This is a wrong-value defect behind a present key, a class the gate has no opinion on by construction.

This was measured, not merely argued. Running the gate against the unfixed action-icon.tsx (restored from 194fae184, mutation confirmed on disk, then restored and verified byte-identical to HEAD) produces output identical to the fixed tree, including the per-surface counts:

✅  action forward parity: 5 surfaces checked against 39 runtime-read keys from 4 consumers; ...
    action:icon    owes 25, forwards 19, payload excess-property CHECKED

Same owes 25, forwards 19, same , same exit 0 in both worlds — because the fix changes a value expression, not the key set.

Could the gate be taught to catch it? Report only — scripts/check-action-forward-parity.mjs is domain:devx territory and is not modified here. The observation, for whoever owns that call: the gate already parses each forward site's payload object literal, so it has the expression for each key in hand and currently uses only the key's presence. A narrow extension would be a per-key value contract for keys the host is known to rename — specifically, that any surface reachable as an action:bar member must spell type as schema.actionType || schema.type, since action-bar.tsx is the single composer of that rename (exactly one site repo-wide). That is a real design decision with a false-positive surface (a renderer legitimately composing type some other way), which is why it is filed as an observation rather than attempted here.

Gates

Derived by reading the CI job step lists under .github/workflows/ (ci.yml Type Check / Test jobs, lint.yml, changeset-presence.yml, changeset-guard.yml, control-bytes.yml, vi-mock-specifiers.yml), not from memory. Exit codes captured before any pipe; each row quotes the gate's own verdict line.

gate verdict line (the gate's own) exit
vitest run packages/components/ Test Files 191 passed (191) / Tests 1736 passed (1736) 0
the new pin alone Test Files 1 passed (1) / Tests 5 passed (5) 0
@object-ui/components type-check tsc --noEmit && tsc -p tsconfig.test.json — no diagnostics 0
check:action-forward-parity ✅ action forward parity: 5 surfaces checked against 39 runtime-read keys from 4 consumers 0
check:vi-mock-specifiers ✅ check-vi-mock-specifiers: OK (3753 tracked source file(s), 2065 test-named; …) 0
check:control-bytes ✅ check-control-bytes: OK (scanned 5235 tracked text file(s); skipped 85 binary). 0
check-changeset-presence.mjs ✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s) 0
changeset:check ✅ All workspace packages are in the changeset fixed group. + ✅ No changeset declares a major bump. 0
pnpm lint (repo-wide) NOT MEASURED as a whole — see below 143

The typecheck green is a real measurement of this diff. tsconfig.test.json includes src/**/*.test.tsx, and --listFiles confirms both the new test file and the fixed action-icon.tsx are program inputs (1 hit each) — so this is not the "typecheck excludes tests" shape where a green says nothing about new coverage.

Lint, stated honestly. The repo-wide pnpm lint (turbo run lint) was attempted and the container's ~10-minute foreground cap killed it with SIGTERM (exit 143) — that reads as not measured, not as red. It got through 46 of 47 tasks first, and @object-ui/components:lint was among them: ✖ 912 problems (0 errors, 912 warnings). The single task cut off was @object-ui/app-shell#lint, which this diff does not touch. No completed task reported any errors.

The narrowed measurement, with the three things that make a narrowing a measurement rather than a skip: (1) the population is read from eslint's own config — 3752 files; (2) file counts come from --format json — both changed files linted, 0 errors; (3) eslint.config.js enables no type-aware linting (no projectService, no parserOptions.project — zero matches), so this diff cannot move the verdict of any file it does not touch. The 12 warnings on action-icon.tsx are pre-existing and were measured against the base file rather than assumed: 12 before, 12 after, delta 0.

One caution for anyone re-running this: a bare eslint . from the repo root is not the gate and reports 89 pre-existing errors across 74 files. The root lint:root script deliberately ignores packages/*/**, so that invocation lints package sources under the wrong config. The gate is turbo run lint (per-package eslint .), which is what the table reports.

Scope

packages/components/src/renderers/action/action-icon.tsx plus its new coverage and one changeset. renderers/overlay/ and packages/app-shell are untouched. No labels attached.


Generated by Claude Code

…tion:icon (objectui#6306)

`action:bar` does not route members through `SchemaRenderer` — it pulls each
member's renderer off the registry and RENAMES the declared type as it spreads
it onto the child: `type` becomes the component id and the real declaration
moves to `actionType`. `action:button` has always resolved that pair when it
forwards (`schema.actionType || schema.type`); `action:icon` read `schema.type`
alone and dropped `actionType`, handing the runner `type: 'action:icon'`.

`ActionRunner.execute` resolves its handler from
`action.type || action.actionType || action.name`, and `'action:icon'` binds no
registered handler and no builtin. For a declaration carrying `target` rather
than `endpoint` it does not reach the legacy `navigate`/`api` fallback either,
so it fell through to `executeActionSchema` and the authored action never ran —
no error, no toast, the objectstack#2169 "Mark Done does nothing" shape. One
authored action executed or did nothing purely as a function of which
`component` the host picked for it.

The `|| schema.type` leg is load-bearing in the other direction: rendered
standalone, `action:icon` never sees an `actionType` and its own registry
`inputs` declare `type` as the action type.

`actionType` joins the props intersection for the same reason it sits on
`action:button`'s: it is the host-composed override read first, and it is not a
`UIActionSchema` key.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3223.5 KB 3266.6 KB
Main entry chunk (gzip) 154.1 KB 350 KB
Entry file index--3bLoSwk.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 10.96KB 4.16KB
app-shell (runtime-config.js) 18.10KB 6.51KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 505.90KB 114.59KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 171.74KB 47.48KB
fields (index.js) 238.89KB 60.02KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.95KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.21KB 44.67KB
plugin-dashboard (index.js) 133.35KB 34.45KB
plugin-designer (index.js) 211.95KB 42.75KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 127.52KB 31.01KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.21KB 54.43KB
plugin-kanban (index.js) 52.83KB 14.55KB
plugin-list (index.js) 111.94KB 27.24KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.70KB 7.69KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.49KB 2.14KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-support-ai
os-support-ai marked this pull request as ready for review August 25, 2026 16:11
@os-support-ai
os-support-ai added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit f9c34df Aug 25, 2026
28 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-6306-action-icon-type-resolution branch August 25, 2026 16:24
Sign up for free to 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.

action:icon as an action:bar member forwards the COMPONENT id as the action type, so the action never reaches a handler (clicks do nothing)

2 participants