…hook (objectui#10274) (objectstack-ai#10342)
Fixes objectstack-ai#10274
Clause-②: no
## What changed
`action:icon` now runs `autoTrigger` through the shared
`useAutoTriggerOnce(schema, isVisible, handleClick)` in
`renderers/action/auto-trigger.ts`. `action:button` and `action:menu`
already call this hook (PR objectstack-ai#10272). The call sits above the icon's
`visible` early return, as it does in the button.
- `run` is the icon's own `handleClick`, the same function its `onClick`
calls. So an auto-triggered run and a click reach `ActionRunner.execute`
with the same `ActionDef` (pinned: the two defs are compared with
`toEqual`) and pass the same confirm gate (pinned: a declined
`confirmText` runs nothing).
- `isVisible` is the verdict the icon's own early return reads. An icon
its author hid is therefore refused, with the `actions.notAvailableHere`
notice and the dev diagnostic, and not run (objectui#4191).
- One changeset, `patch` for `@object-ui/components`.
No declared key, schema, export or accept set moves. `auto-trigger.ts`
is not edited.
## Readings for the dispatch's mechanism assumptions
- **A1: reproduced on main before the change** (base `1dbb993`, after PR
objectstack-ai#10272 landed). The new test file ran 7 failed / 1 passed. In the card's
probe row the handler was called 1 time, not 2. That one call is the
flagged `action:button` control's: on base, `action:icon` has no path
that executes without a click, so the flagged icon member beside it in
the same bar ran nothing. The hidden-icon row read 0 notices. The one
green row was "an inline icon WITHOUT the flag runs nothing on mount".
After the change: 8/8.
- **A2: the run path is confirmed; the visibility verdict is the icon's
own, and it is not the button's.** The hook gets `handleClick`, so the
auto-triggered run and a click go through the same `execute`.
`isVisible` is computed exactly as the icon's early return computes it.
That differs from `action:button` in one way: the icon evaluates
`visible` without `throwOnError` (fail-soft), while the button uses it
(fail-closed). That policy is pinned as it stands by
`action-record-predicate-root.test.tsx` (the row "`visible` keeps its
EXISTING fail-soft policy on a faulting predicate", whose comment says
the policy is "Not what this PR decides"), and it is tabled in
`action-template-predicate-gate.test.tsx`. This PR keeps the hook's
contract (the verdict is "the same value its early return consults"), so
the trigger follows what the icon renders. The only case where that
matters is a `visible` predicate that THROWS: an inline icon renders and
now runs, where a button hides and refuses. See Acceptance notes.
- **A3: once-only.** Pinned: three re-renders with fresh action objects
and the flag still set, after the run, leave 1 execution. The flag
flipping true later runs exactly once. A throwaway probe (not committed)
measured one more case: unmount and remount with the flag STILL set runs
again (1 then 2), and `action:button` does the same (1 then 2). The
guard is per mounted component, as `auto-trigger.ts` documents. On the
deep-link path the host drops the flag after the first commit
(`useConsumeOnce` returns null once it has consumed the param), so a
later remount has nothing to run.
- **A4: other renderers.** Probe (not committed): one `action:bar`, an
unflagged control plus one member flagged `autoTrigger: true`, varying
only the member's `component`. `action:button`: execute=1, rendered.
`action:icon`: execute=1, rendered (after this change). `action:menu`:
execute=0, and the member is not rendered at all. `action:group`:
execute=0, not rendered at all. This is reported as an out-of-scope
finding in the dev report and is not changed here.
## Tests and gates (final HEAD `3b6341b`)
- `pnpm exec vitest run packages/components/src/renderers/action/` plus
the 20 test files outside that directory whose source mentions
`autoTrigger`, `runAction`, `action:icon` or `action-icon`. That
includes the PR objectstack-ai#10272 pins in `action-overflow-autotrigger.test.tsx`
and `useOfferedNavRunAction.test.tsx` and the environment toolbar
deep-link suites. Result: **41 files, 913 tests passed**.
- Declared narrowing: this is not the whole `@object-ui/components`
suite (290 test files; CI runs all of them). The hook does nothing for
an action without `autoTrigger: true` beyond reading the i18n context,
because its effect returns first. Every test file that names the flag,
the deep link or the icon is in the run. Blind spot: a test that reaches
an icon through a fixture file without naming any of those in its own
source.
- `pnpm --filter @object-ui/components run type-check` (both `tsc
--noEmit` and `tsc -p tsconfig.test.json`): exit 0, run after building
the dependency closure (`pnpm --filter '@object-ui/components^...' run
build`, exit 0). The test program does include the new file: its first
run reported a type error in it, corrected in `3b6341b`.
- eslint on the two touched source files, with the components package
config and inline config honoured as `pnpm lint` runs it: 0 errors.
`action-icon.tsx` has 8 warnings, the same 8 as on base. The new test
file has 0.
- `check-changeset-presence`, `check-changeset-no-major`,
`check-changeset-fixed`, `check:new-line-citations` (0 new),
`check:control-bytes`, `check:test-path-roots`,
`check:action-forward-parity`, `check-changeset-claims`,
`check:pending-changeset-literals` and `check-vi-mock-override-shape`:
all exit 0 at `3b6341b`.
## Ablation (committed state, `ablation-replace.mjs`)
In `action-icon.tsx`, the line `useAutoTriggerOnce(schema, isVisible,
handleClick);` was deleted. The mutation landed (anchor 1 to 0, blob
`8e859d91f90a` to `234f056ef052`). New test file: 7 failed / 1 passed,
the same rows that were red on base. Restored: the blob equals HEAD and
`git diff HEAD` is empty. The test imports the renderer by relative
path, so there is no build leg.
## Pending changesets
- `4191-autotrigger-honours-visible.md` (PR objectstack-ai#10272) names
`action:button` and `action:menu` and says both go through the one
shared hook. That is still true, and it does not claim they are the only
two, so it is left as is. Its sentence that the receiving renderer
"cannot change the outcome" is closer to true after this change than
before it.
- `6306-action-icon-type-resolution.md` names `action-icon.tsx`, so
`check-changeset-claims` asks for a read. Its scope paragraph counts
`type: schema` in two files under `renderers/action/`. This diff does
not change that count (the same files carry it on base and HEAD), so it
is left as is. Separately, and not caused by this diff: that changeset
quotes the button's forward as `schema.actionType || schema.type`, and
objectui#7415 later removed the `|| schema.type` half.
## Acceptance notes
- **The icon's fail-soft `visible` against the fail-closed deep-link
preparation step.** `ObjectView` arms `?runAction=` only after
`useOfferedNavRunAction` evaluates the candidate's `visible`
fail-closed, which is the button's policy. For an action rendered as an
icon whose `visible` predicate throws, the icon is shown (fail-soft)
while the deep link is refused with the "not available on the current
page" notice. This existed before this PR and does not depend on it
(before, the icon ran nothing in either case). It is reachable only
through a predicate that throws, which is an authoring error, and the
icon's policy is pinned as undecided. Not filed.
- **Comments outside this PR's file surface.** `auto-trigger.ts` still
names `action:button` and `action:menu` as the hook's consumers, and
describes each renderer's verdict as "the same fail-closed
`useCondition`". The icon's verdict is fail-soft. `useNavRunAction.ts`
names the same two renderers. These are comments only and are not edited
here, because they are outside the claimed file surface.
- **`action:group`'s inner actions** (its inline buttons and dropdown
items) do not read `autoTrigger`. No host puts the flag on a group's
inner actions today, so nothing reaches that path.
Dispatched dev for seat `domain:ui#4`, session
`https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
Fixes #4191
Clause-②: no
What changed
Ruling A (comment 5812953871, maintainer 「objectui 第⑦批 同意」): an action's own declared
visibleoutranksautoTrigger. A hidden action is not run, and the refusal is reported, not swallowed.Renderers (
@object-ui/components). The shareduseAutoTriggerOnceinrenderers/action/auto-trigger.tsnow takes the action plus the renderer's ownvisibleverdict (the same fail-closeduseConditionvalue its early return reads). The "is it hidden?" test is written once in that hook, usinghasDeclaredVisibilityGate, the same test the early returns use. If the action is hidden, the hook does not execute it. It shows atoast.warningnaming the action (new keyactions.notAvailableHere) and, in non-production builds, writes aconsole.warndiagnostic naming the action and its predicate. Both happen at most once per mounted action.action:buttonandaction:menuboth call this one hook, so they refuse identically (the #4162 parity principle). Inaction:menu, the headlessActionAutoTriggernow computes its action's verdict through the same local hookActionMenuItemuses (useMenuActionVisible), against the same row.A refusal does not use up the once-guard. The gate is re-checked on every commit, so an action whose predicate turns true later in the same mount (for example, an ambient scope that arrives after first paint) still runs once. This is pinned in both renderers.
Deep-link preparation (
@object-ui/app-shell).ObjectViewnow arms therunActionnav deep link through a new internal hook,useOfferedNavRunActioninhooks/useNavRunAction.ts. The hook finds the candidate action (same placement check as before,actionRendersAt(a, 'list_toolbar')) and evaluates itsvisibleusing the renderer's own predicate, built from the same exported functions with the same inputs:hasDeclaredVisibilityGate, a fail-closeduseCondition(toPredicateInput(visible), usePredicateRecordContext(undefined), { throwOnError: true, label }), and the ambient predicate scope read at the same tree position. A list toolbar has no row, becauseaction:baris mounted there withoutdata. If the candidate is hidden, it is not markedautoTrigger, the param is not stripped (the one-shot intent survives), and the same notice key plus a dev diagnostic are emitted. There is no second evaluator. A parity table drives ninevisibleshapes through the prep step and through the realaction:bar, and requires the same verdict from both: undeclared, empty,true/false, true and false predicates, a throwing predicate (fail-closed), and an ambient-scope predicate both ways. The consume-once logic thatuseNavRunActionand the new hook share is extracted verbatim intouseConsumeOnce.useNavRunAction's behaviour is unchanged, andEnvironmentListToolbarstill uses it.i18n. New key
actions.notAvailableHerein all ten locale packs. It is in theactionsblock, away from seat 1'ssearch.resultsCount*keys. The de quote-pairing count pin inde-quote-pairing-3876.test.tsgoes from 62 to 63 because the new de value contains one more matched pair of German quotes.Not an authorization hole, and this PR does not make it one. Confirm dialogs, param collection, entitlement checks and server-side permissions apply on every execute path, before and after this change. The change protects the author's rule about where an action may be offered.
The pin inversion
The
action-overflow-autotrigger.test.tsxtest titled "the ACTION's own declared visible gate does not suppress it — and inline agrees with overflow" is rewritten as "…REFUSES it, and says so — inline agrees with overflow (objectui#4191)". It is not deleted. It asserts no execution, one notice naming the action and one dev diagnostic in each of the inline and overflow layouts. Three sibling tests were added: the notice appears once however many re-renders happen, a refusal does not use up the once-guard (checked in both renderers), and an empty predicate is not a gate.Grep for other pins that assert execute-despite-hidden (tracked files,
.changeset/excluded):gate does not suppress it: 1 hit on base0427036(control, lit), 0 on HEAD.execute=1: 1 hit, which is the historical note inside the inverted test.gate does not suppress the trigger: 1 hit, inpackages/components/CHANGELOG.md. That is published history and stays unedited.does not suppress: 10 hits. All are unrelated except that same CHANGELOG line.autoTriggertogether withvisiblein*.test.*: every hit is in the inverted file.No contrary pin is left.
Measurements
0427036):action-overflow-autotrigger.test.tsxran 12/12 green, including the old pin that asserts the hidden action executes, in both renderers. That is the card'srendered="" execute=1measured on the current tree.ablation-replace.mjs: inauto-trigger.ts,const hidden = hasDeclaredVisibilityGate(action.visible) && !isVisible;was replaced withconst hidden = false;. The mutation landed (anchor 1 to 0, blobdcd4daf8cc72to5cbfef12012f). Result: 3 failed / 12 passed, all three being the refusal tests. The file was restored (blob equals HEAD,git diff HEADempty). Components resolve through the vitest source alias, so no build leg was involved.useNavRunAction.ts, the arm conditioncandidate !== undefined && !hiddenwas replaced withcandidate !== undefined. The mutation landed (blobdf480aa988cetoe0c4fec930c5). Result: 6 failed / 8 passed: the hidden-candidate test, the once-notice test, and four parity rows (literal false, false predicate, throwing predicate, scope says no). Restored,git diff HEADempty.Tests and gates (final HEAD
23cbae7, aftergit merge origin/main)pnpm exec vitest run packages/components/src/renderers/action/ packages/i18n/plus 41 app-shell files (everyObjectViewimporter and everyuseNavRunAction/autoTriggertest): 130 files / 1903 tests passed.e64fad5: fullpackages/components/+packages/i18n/: 357 files, where the only failure was the de quote-count pin (fixed in23cbae7, i18n re-run 70/70). Components source is unchanged since.type-checkfor components, app-shell and i18n: exit 0 after building the app-shell dependency closure with turbo.--listFilesconfirms both new or edited test files are in theirtsconfig.test.json.lintfor the same three packages: exit 0 (0 errors).check:vi-mock-specifiers,check:vi-mock-inherit,check:vi-mock-override-shape,check:changeset-claims,check:i18n-keys,check:i18n-drift,check:i18n-dead-keys,check:i18n-designer-parity,check:new-line-citations,check:control-bytes,check:action-forward-parity,check:test-path-roots,check:unreferenced-sources,check:pending-changeset-literals,check-changeset-presence.mjs,check:eager-locale-catalogues,check:eager-closure: all exit 0. The two eager gates were run after a console build, because they readapps/console/dist.check:*gates. CI runs them.Acceptance notes
EnvironmentListToolbar(onsys_environment) arms its own deep link through the unchangeduseNavRunAction. It was outside this card's file surface and the ruling's ObjectView clause. Ifcreate_environmentever declares avisiblethat evaluates false, the renderers now refuse it and show the notice, but that toolbar's preparation step still consumes the URL param. I did not measure whether any deployedcreate_environmentdeclaresvisible.ObjectView's preparation step still does not applyaction:bar's capability gate (requiredPermissions). That behaviour predates this card and is unchanged here.action:icondoes not consumeautoTriggerat all. Probe on this branch: an inlinecomponent: 'action:icon'member gaveicon_execute=0, with controlbutton_execute_control=1. This is reported separately and not fixed here.Generated by Claude Code