diff --git a/.changeset/action-menu-autotrigger-overflow-4162.md b/.changeset/action-menu-autotrigger-overflow-4162.md
new file mode 100644
index 0000000000..44bbb23ac2
--- /dev/null
+++ b/.changeset/action-menu-autotrigger-overflow-4162.md
@@ -0,0 +1,13 @@
+---
+'@object-ui/components': patch
+---
+
+An `autoTrigger` action that spills past `action:bar`'s `maxVisible` now still runs — `action:menu` consumes the flag instead of dropping it.
+
+`autoTrigger` is the client-composed "run this action as soon as a renderer receives it" flag behind deep links like the welcome page's "Create your environment" CTA (#844). It was consumed only by `action:button`. `action:bar` splits its post-gate list at `maxVisible` (3 on desktop, 1 on mobile) and hands the tail to `action:menu`, which had no `autoTrigger` handling at all — so an auto-triggered action that happened to sort past that threshold was rendered as an ordinary "More" menu entry and never ran, while the caller had already spent the one-shot signal it stood for. The `?runAction=create_environment` deep link is consumed by stripping it from the URL, so the measured end state was `urlParam=null execute=0`: no dialog, and no URL left to retry from. Which actions lost their auto-trigger was partly a function of viewport width, since `maxVisible` drops to 1 on mobile, and `systemActions` — always in the overflow menu, whatever the viewport — could never fire one at all.
+
+The flag's contract is now stated and enforced as "execute once on mount by whichever renderer receives the action". `action:menu` consumes it by EXECUTING, through the same path a click on that item takes; it does not open the dropdown, so a transport flag never moves what the user sees. Consumption happens where the action provably arrives — the menu renderer receiving it — not in the menu items, which Radix mounts only once the dropdown opens and which would therefore have waited on the very click the flag exists to avoid.
+
+Once-ness has one implementation (`renderers/action/auto-trigger.ts`), now shared by both renderers rather than written twice: a guard ref per rendered action, so re-renders never re-fire it and a flag that flips true later still fires exactly once. Container visibility still governs mounting — a hidden `action:bar` or `action:menu` renders no children and auto-triggers nothing — while the action's own `visible` gate does not suppress the trigger, matching `action:button`'s long-standing behaviour so that a deep link cannot depend on where the bar happened to put the action.
+
+The `action:bar` split, the inline `action:button` path and #4166's arming pins are unchanged.
diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx
new file mode 100644
index 0000000000..5ee910a606
--- /dev/null
+++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkOverflow.test.tsx
@@ -0,0 +1,162 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * #4162 — the `?runAction=create_environment` deep link when the create action
+ * OVERFLOWS, measured end-to-end on the real toolbar.
+ *
+ * This is #4123's signature one layer deeper. #4166 fixed what ARMS the deep
+ * link (the create action's presence, not "any toolbar action"); arming keys on
+ * the post-gate list, which INCLUDES the actions `action:bar` is about to move
+ * into the overflow menu. So arming is correct here — it fires, the param is
+ * stripped, `autoTrigger` is attached — and the action is then handed to
+ * `action:menu`, which used to have no `autoTrigger` handling at all. Measured
+ * on the filing's setup before the fix:
+ *
+ * PROBE-OVERFLOW: urlParam=null execute=0 buttons=["a1","a2","a3",""]
+ *
+ * The same end state as #4123 (`urlParam=null execute=0`, the intent spent on
+ * nothing and unrecoverable because the strip IS the consumption), reached
+ * through a different mechanism and NOT closed by #4166.
+ *
+ * The fix is in `@object-ui/components` (`action:menu` consumes `autoTrigger`
+ * by executing); this file is the consumer-side proof that the #844 welcome-page
+ * flow now survives a toolbar shape where the create action is fourth. The
+ * sibling `EnvironmentListToolbar.deepLinkArming.test.tsx` (#4123/#4166) pins
+ * the arming half and is deliberately untouched.
+ *
+ * The registry import is at module scope (not in a `beforeAll`) per AGENTS.md
+ * §测试纪律 — `action:bar` resolves its members through the ComponentRegistry at
+ * render time, and the cost belongs in the import phase where no hook timeout
+ * applies.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+// Side-effect import: registers `action:bar` / `action:button` / `action:menu`
+// in the ComponentRegistry that the real `SchemaRenderer` resolves against.
+import '@object-ui/components';
+import { ActionProvider } from '@object-ui/react';
+import { I18nProvider } from '@object-ui/i18n';
+import { EnvironmentListToolbar } from '../EnvironmentListToolbar';
+import type { EnvironmentEntitlementsState } from '../entitlements';
+
+/**
+ * The card's shape: a create action that declares neither `order` nor
+ * `variant: 'primary'`, registered LAST. `action:bar`'s `needsOrdering` is then
+ * false, so registration order stands and the 4th action lands past the desktop
+ * `maxVisible: 3` — in the overflow menu.
+ *
+ * The `setup_production` state is safe today only by accident: the toolbar's own
+ * label override gives the create action `variant: 'primary'`, which flips
+ * `needsOrdering` and floats it into the primary slot. `add_development` applies
+ * no variant, which is why this file uses it.
+ */
+const CREATE = {
+ name: 'create_environment',
+ label: 'Create Environment',
+ type: 'api',
+ locations: ['list_toolbar'],
+};
+const FILLERS = [
+ { name: 'a1', label: 'a1', type: 'api', locations: ['list_toolbar'] },
+ { name: 'a2', label: 'a2', type: 'api', locations: ['list_toolbar'] },
+ { name: 'a3', label: 'a3', type: 'api', locations: ['list_toolbar'] },
+];
+
+/** ready + has production + may create a dev env → `add_development`, no variant override. */
+const ADD_DEVELOPMENT: EnvironmentEntitlementsState = {
+ ready: true,
+ hasProductionEnv: true,
+ canCreateDevelopmentEnv: true,
+ upgradeUrl: '/settings/billing',
+ source: 'summary',
+} as EnvironmentEntitlementsState;
+
+const runActionParam = () => new URL(window.location.href).searchParams.get('runAction');
+
+let origReplaceState: typeof window.history.replaceState;
+
+function deepLink() {
+ const url = new URL(window.location.href);
+ url.searchParams.set('runAction', 'create_environment');
+ origReplaceState.call(window.history, null, '', url);
+}
+
+beforeEach(() => {
+ origReplaceState = window.history.replaceState.bind(window.history);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ origReplaceState.call(window.history, null, '', '/');
+});
+
+function mountStack(actions: any[]) {
+ const execute = vi.fn(async () => ({ success: true }));
+ const view = render(
+
+
+
+
+ ,
+ );
+ return { execute, ...view };
+}
+
+describe('the create deep link survives the create action overflowing (#4162)', () => {
+ it("the card's probe: create action 4th → rendered by action:menu → still executes, once", async () => {
+ deepLink();
+ const { execute } = mountStack([...FILLERS, CREATE]);
+
+ // The overflow really happened — three inline buttons and a "More" trigger,
+ // with no create button of its own. This is the card's `buttons=[…]` line.
+ expect(await screen.findByRole('button', { name: 'a1' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'a3' })).toBeTruthy();
+ expect(screen.queryByRole('button', { name: /Add environment/i })).toBeNull();
+ expect(screen.getByRole('button', { name: /more actions/i })).toBeTruthy();
+
+ // Was `execute=0`. The param is still consumed exactly once — that half was
+ // never the defect — but now something actually runs.
+ await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
+ expect((execute.mock.calls[0][0] as any).name).toBe('create_environment');
+ await waitFor(() => expect(runActionParam()).toBeNull());
+ });
+
+ it('and it does not open the menu to do it', async () => {
+ deepLink();
+ const { execute } = mountStack([...FILLERS, CREATE]);
+
+ await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole('menu')).toBeNull();
+ });
+
+ it('no deep link → the overflowed create action just sits in the menu', async () => {
+ // The refusal half at this layer: nothing about overflow makes an action run
+ // on its own. Without `?runAction`, `useAutoRunCreate` never arms, so no
+ // `autoTrigger` is attached and the menu executes nothing.
+ const { execute } = mountStack([...FILLERS, CREATE]);
+
+ expect(await screen.findByRole('button', { name: 'a1' })).toBeTruthy();
+ await new Promise((r) => setTimeout(r, 0));
+ expect(execute).not.toHaveBeenCalled();
+ expect(runActionParam()).toBeNull();
+ });
+
+ it('the inline case is unchanged: create action first → action:button runs it, once', async () => {
+ // #4166's path, re-measured here so a regression in either direction is
+ // visible from one file.
+ deepLink();
+ const { execute } = mountStack([CREATE, ...FILLERS.slice(0, 2)]);
+
+ await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(runActionParam()).toBeNull());
+ expect((execute.mock.calls[0][0] as any).name).toBe('create_environment');
+ });
+});
diff --git a/packages/components/src/renderers/action/__tests__/action-overflow-autotrigger.test.tsx b/packages/components/src/renderers/action/__tests__/action-overflow-autotrigger.test.tsx
new file mode 100644
index 0000000000..948780302b
--- /dev/null
+++ b/packages/components/src/renderers/action/__tests__/action-overflow-autotrigger.test.tsx
@@ -0,0 +1,325 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#4162 — `autoTrigger` is a property of the ACTION, so every renderer
+ * that receives that action must honour it. It used to be consumed only by
+ * `action:button`.
+ *
+ * `action:bar` splits its post-gate list at `maxVisible` (3 desktop, 1 mobile)
+ * and hands the tail to `action:menu`. So an action carrying `autoTrigger: true`
+ * that sorts past that threshold was rendered — present, reachable, clickable in
+ * the "More" menu — while its auto-trigger was silently dropped. The caller that
+ * set the flag has already spent the signal it stands for: the #844 deep link
+ * (`?runAction=create_environment`) is consumed by stripping it from the URL, so
+ * the card measured `urlParam=null execute=0` — intent gone, nothing run, and no
+ * URL left to retry from. Which actions lose their auto-trigger was a function of
+ * viewport width, since `maxVisible` drops to 1 on mobile.
+ *
+ * The ruling (on the card): the flag's contract is **execute once on mount by
+ * whichever renderer receives the action**. `action:menu` therefore consumes it
+ * by EXECUTING — not by rendering something and hoping the user clicks it, and
+ * not by auto-opening the dropdown. The two rejected alternatives are on the
+ * card: making `autoTrigger` an ordering key in `action:bar` moves VISUAL layout
+ * as a side effect of a transport flag, and rejecting the flag loudly turns a
+ * working deep link into an error for the crime of being fourth in a list.
+ *
+ * Sibling defect, same end signature one layer up (what ARMS the deep link):
+ * #4123 / PR #4166, whose pins live in
+ * `packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.deepLinkArming.test.tsx`
+ * and are consumers of this contract.
+ *
+ * ## What each block is defended against
+ *
+ * The inline block is not decoration: a "just execute every action the menu
+ * receives" rewrite passes every overflow assertion here on its own, and the
+ * non-autoTrigger block is what refuses it. Likewise the once-ness blocks —
+ * a menu-level boolean guard and a per-action guard differ only when two
+ * actions carry the flag, and only the last block tells them apart.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { ComponentRegistry } from '@object-ui/core';
+import { ActionProvider } from '@object-ui/react';
+// Module-scope side-effect imports — `action:bar` resolves its members (and the
+// overflow menu) through the ComponentRegistry at render time, and the light
+// `dom` project deliberately does not load the `@object-ui/components` graph.
+// Module scope, not a `beforeAll`, per AGENTS.md §测试纪律.
+import '../action-bar';
+import '../action-button';
+import '../action-menu';
+
+/** Three ordinary toolbar actions — enough to fill the desktop `maxVisible: 3`. */
+const FILLERS = [
+ { name: 'a1', label: 'a1', type: 'api', locations: ['list_toolbar'] },
+ { name: 'a2', label: 'a2', type: 'api', locations: ['list_toolbar'] },
+ { name: 'a3', label: 'a3', type: 'api', locations: ['list_toolbar'] },
+];
+
+/**
+ * The card's action: a create action that declares neither `order` nor
+ * `variant: 'primary'`, so `needsOrdering` is false and the bar keeps
+ * registration order — which puts it 4th, i.e. in the overflow menu.
+ */
+const CREATE = {
+ name: 'create_environment',
+ label: 'Create Environment',
+ type: 'api',
+ locations: ['list_toolbar'],
+};
+
+let api: ReturnType;
+
+beforeEach(() => {
+ api = vi.fn(async () => ({ success: true }));
+});
+
+/** The names the runner was actually asked to execute, in order. */
+const executed = () => api.mock.calls.map((c) => (c[0] as any).name);
+
+function Bar({ actions }: { actions: any[] }) {
+ const C = ComponentRegistry.get('action:bar');
+ if (!C) throw new Error('action:bar is not registered');
+ // eslint-disable-next-line react-hooks/static-components -- ComponentRegistry.get returns a registered renderer (stable reference), not a component created during render
+ return ;
+}
+
+function renderBar(actions: any[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('action:menu consumes autoTrigger for the actions it receives (#4162)', () => {
+ it("the card's probe: an autoTrigger action that spills past maxVisible still executes, exactly once", async () => {
+ // Before the fix this measured `execute=0` with the same DOM:
+ // `buttons=["a1","a2","a3",""]` — three inline buttons plus the overflow
+ // trigger, the create action present in the menu and never run.
+ renderBar([...FILLERS, { ...CREATE, autoTrigger: true }]);
+
+ // It really did overflow: only the three fillers are inline, and the
+ // create action has no button of its own.
+ expect(screen.getByRole('button', { name: 'a1' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'a3' })).toBeTruthy();
+ expect(screen.queryByRole('button', { name: 'Create Environment' })).toBeNull();
+
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+ expect(executed()).toEqual(['create_environment']);
+ });
+
+ it('executes WITHOUT opening the menu — the dropdown stays closed', async () => {
+ // Consumption is execution, not "render the item and hope". An auto-opened
+ // dropdown would be a visible side effect of a transport flag, and it is
+ // also how a naive fix (mount the items eagerly) would announce itself.
+ renderBar([...FILLERS, { ...CREATE, autoTrigger: true }]);
+
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole('menu')).toBeNull();
+ expect(screen.queryByRole('menuitem')).toBeNull();
+ // The item's label is nowhere in the document — the menu content never mounted.
+ expect(screen.queryByText('Create Environment')).toBeNull();
+ const trigger = screen.getByRole('button', { name: /more actions/i });
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
+ });
+
+ it('re-renders do not re-fire it', async () => {
+ const view = renderBar([...FILLERS, { ...CREATE, autoTrigger: true }]);
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+
+ // Fresh action objects each time, exactly as a state-dependent toolbar
+ // produces (`EnvironmentListToolbar` re-`.map()`s its actions every render).
+ for (let i = 0; i < 3; i++) {
+ view.rerender(
+
+
+ ,
+ );
+ }
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).toHaveBeenCalledTimes(1);
+ });
+
+ it('the flag flipping true LATER still triggers exactly once', async () => {
+ // The `action:button` case this mirrors: `EnvironmentListToolbar` attaches
+ // `autoTrigger` only once entitlements resolve, so the first commit carries
+ // the action without the flag.
+ const view = renderBar([...FILLERS, CREATE]);
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).not.toHaveBeenCalled();
+
+ view.rerender(
+
+
+ ,
+ );
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+
+ view.rerender(
+
+
+ ,
+ );
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).toHaveBeenCalledTimes(1);
+ });
+
+ it('an overflow action WITHOUT the flag is untouched — nothing runs on mount', async () => {
+ // The refusal half. "The menu executes what it is given" would pass every
+ // assertion above and fire this one.
+ renderBar([...FILLERS, CREATE, { name: 'a5', label: 'a5', type: 'api', locations: ['list_toolbar'] }]);
+
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).not.toHaveBeenCalled();
+ expect(screen.getByRole('button', { name: /more actions/i })).toBeTruthy();
+ });
+
+ it('systemActions — always overflow, never inline — are honoured the same way', async () => {
+ // `systemActions` bypass the `maxVisible` split entirely and are ALWAYS
+ // rendered by the menu, so before this fix an `autoTrigger` there could
+ // never fire, whatever the viewport.
+ render(
+
+ {(() => {
+ const C = ComponentRegistry.get('action:bar')!;
+ return (
+
+ );
+ })()}
+ ,
+ );
+
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+ expect(executed()).toEqual(['create_environment']);
+ });
+
+ it('the once-guard is per ACTION, not per menu: two flagged overflow actions each run once', async () => {
+ // A single boolean guard on the menu would swallow the second one and still
+ // satisfy every block above.
+ renderBar([
+ ...FILLERS,
+ { ...CREATE, autoTrigger: true },
+ { name: 'sync_now', label: 'Sync now', type: 'api', locations: ['list_toolbar'], autoTrigger: true },
+ ]);
+
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(2));
+ expect(executed().sort()).toEqual(['create_environment', 'sync_now']);
+ });
+});
+
+describe('the inline path is unchanged by #4162', () => {
+ it('an autoTrigger action that fits inline is still run by action:button, once', async () => {
+ // #844's original path — the one PR #4166 pins end-to-end. If the fix had
+ // been placed in `action:bar` (dispatching the flag itself), this would
+ // double-fire: bar AND button.
+ renderBar([{ ...CREATE, autoTrigger: true }, ...FILLERS.slice(0, 2)]);
+
+ expect(screen.getByRole('button', { name: 'Create Environment' })).toBeTruthy();
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+ await new Promise((r) => setTimeout(r, 0));
+ expect(executed()).toEqual(['create_environment']);
+ });
+
+ it('an inline action without the flag still does nothing on mount', async () => {
+ renderBar([CREATE, ...FILLERS.slice(0, 2)]);
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).not.toHaveBeenCalled();
+ });
+});
+
+describe('what suppresses an auto-trigger, and what does not (#4162)', () => {
+ /** A predicate that is declared, evaluable and false — so the gate really runs. */
+ const NEVER = '1 == 2';
+
+ it('a CONTAINER that renders nothing mounts nothing: a hidden action:bar fires neither path', async () => {
+ // The rule the menu's placement follows. `action:bar` returns null before
+ // its children mount, so an inline `autoTrigger` button never fires — and
+ // the overflow menu is not built either. Both halves in one bar so the
+ // assertion cannot pass for only one of them.
+ render(
+
+ {(() => {
+ const C = ComponentRegistry.get('action:bar')!;
+ return (
+
+ );
+ })()}
+ ,
+ );
+
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).not.toHaveBeenCalled();
+ });
+
+ it('…and a hidden action:menu is the same container rule', async () => {
+ render(
+
+ {(() => {
+ const C = ComponentRegistry.get('action:menu')!;
+ return (
+
+ );
+ })()}
+ ,
+ );
+
+ await new Promise((r) => setTimeout(r, 0));
+ expect(api).not.toHaveBeenCalled();
+ });
+
+ it("the ACTION's own declared visible gate does not suppress it — and inline agrees with overflow", async () => {
+ // NOT an endorsement of the semantics — a parity pin. `action:button`
+ // declares its auto-trigger effect before its `visible` early return, so a
+ // gated-invisible action executes anyway (measured on this tree, before any
+ // change: `rendered="" execute=1`). Whether that is right is one question
+ // for BOTH renderers, filed separately as objectui#4191; what this card
+ // cannot allow is the two disagreeing, because "which renderer got the
+ // action" is decided by `maxVisible` and the viewport.
+ const hidden = { ...CREATE, visible: NEVER, autoTrigger: true };
+
+ const inline = render(
+
+
+ ,
+ );
+ await waitFor(() => expect(api).toHaveBeenCalledTimes(1));
+ // It really was hidden: no button of its own anywhere.
+ expect(screen.queryByRole('button', { name: 'Create Environment' })).toBeNull();
+ inline.unmount();
+
+ const overflowApi = vi.fn(async () => ({ success: true }));
+ render(
+
+
+ ,
+ );
+ await waitFor(() => expect(overflowApi).toHaveBeenCalledTimes(1));
+ expect((overflowApi.mock.calls[0][0] as any).name).toBe('create_environment');
+ });
+});
diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx
index e4b3bad0f3..b5962d1b49 100644
--- a/packages/components/src/renderers/action/action-button.tsx
+++ b/packages/components/src/renderers/action/action-button.tsx
@@ -17,7 +17,7 @@
* - Variant / size / className overrides from schema
*/
-import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react';
+import React, { forwardRef, useCallback, useState } from 'react';
import { ComponentRegistry } from '@object-ui/core';
import type { ActionSchema } from '@object-ui/types';
import { useAction } from '@object-ui/react';
@@ -27,6 +27,7 @@ import { cn } from '../../lib/utils';
import { Loader2 } from 'lucide-react';
import { resolveIcon } from './resolve-icon';
import { hasDeclaredVisibilityGate } from './visibility-gate';
+import { hasAutoTrigger, useAutoTriggerOnce } from './auto-trigger';
export interface ActionButtonProps {
schema: ActionSchema & { type: string; className?: string; actionType?: string };
@@ -147,20 +148,13 @@ const ActionButtonRenderer = forwardRef(
// Client-side auto-trigger (#844): a caller (e.g. a welcome-page CTA that
// deep-links into "create") can mark an action `autoTrigger: true` to run
- // it once as soon as the button mounts — the exact same execute path as a
- // click, so param dialogs / confirms / entitlement gates all still apply.
- // NOT persisted metadata: the flag only exists on client-composed schemas.
- // The ref guards re-fires across re-renders; the flag flipping true later
- // (state-dependent toolbars) still triggers exactly once.
- const autoTriggered = useRef(false);
- const autoTrigger = (schema as any).autoTrigger === true;
- useEffect(() => {
- if (!autoTrigger || autoTriggered.current) return;
- autoTriggered.current = true;
- void handleClick();
- // handleClick identity changes with schema/context churn; the ref makes
- // this once-only regardless, so it's safe to depend on it.
- }, [autoTrigger, handleClick]);
+ // it once as soon as the button receives it — the exact same execute path
+ // as a click, so param dialogs / confirms / entitlement gates all still
+ // apply. The guard and the flag test live in `./auto-trigger` because this
+ // is no longer the only consumer: `action:menu` runs the same contract for
+ // the actions that spill past `action:bar`'s `maxVisible` (#4162), and
+ // once-ness written twice is two behaviours waiting to drift.
+ useAutoTriggerOnce(hasAutoTrigger(schema), handleClick);
// A declared boolean `visible: false` is a verdict, not a missing gate —
// truthiness classified it as "ungated" and rendered the action for
diff --git a/packages/components/src/renderers/action/action-menu.tsx b/packages/components/src/renderers/action/action-menu.tsx
index 853150ba40..adbc1fcd5f 100644
--- a/packages/components/src/renderers/action/action-menu.tsx
+++ b/packages/components/src/renderers/action/action-menu.tsx
@@ -31,6 +31,7 @@ import { cn } from '../../lib/utils';
import { Loader2, MoreHorizontal } from 'lucide-react';
import { resolveIcon } from './resolve-icon';
import { hasDeclaredVisibilityGate } from './visibility-gate';
+import { hasAutoTrigger, useAutoTriggerOnce } from './auto-trigger';
function useMoreActionsLabel(): string {
// useObjectTranslation is provider-safe (never throws); no try/catch, which
@@ -137,6 +138,40 @@ export const ActionMenuItem: React.FC<{
ActionMenuItem.displayName = 'ActionMenuItem';
+/**
+ * The menu's `autoTrigger` consumption point (#4162) — renders NOTHING and
+ * executes its action once, through the same `handleExecute` a click uses.
+ *
+ * ## Why a headless component per action, and not the item
+ *
+ * The consumption point has to be where the action provably ARRIVES, which is
+ * this renderer receiving it in `schema.actions`. It cannot be `ActionMenuItem`:
+ * the items live inside `DropdownMenuContent`, which Radix mounts only when the
+ * dropdown OPENS, so an effect there would wait on the very click the flag
+ * exists to avoid — and would make the trigger's open state, not the action,
+ * decide whether a deep link runs. These mount with the menu itself.
+ *
+ * ## Why a component rather than a loop of hooks
+ *
+ * One `useAutoTriggerOnce` per action is the point (the guard is per action, so
+ * two flagged actions each run once), and hooks cannot be called in a loop.
+ * One instance per action, keyed by name, gives each its own guard ref for the
+ * menu's lifetime. They are rendered for EVERY action, not only the flagged
+ * ones, so the ref survives the flag flipping — mounting on the flip and
+ * unmounting on the flip-back would hand a true→false→true action a fresh ref
+ * and fire it twice, where `action:button`'s long-lived ref fires once.
+ */
+const ActionAutoTrigger: React.FC<{
+ action: ActionSchema;
+ onExecute: (action: ActionSchema) => Promise;
+}> = ({ action, onExecute }) => {
+ const run = useCallback(() => onExecute(action), [action, onExecute]);
+ useAutoTriggerOnce(hasAutoTrigger(action), run);
+ return null;
+};
+
+ActionAutoTrigger.displayName = 'ActionAutoTrigger';
+
const ActionMenuRenderer = forwardRef(
({ schema, className, ...props }, ref) => {
const {
@@ -214,48 +249,72 @@ const ActionMenuRenderer = forwardRef
-
-
-
+ <>
+ {/*
+ `autoTrigger` is a property of the ACTION, so this renderer honours it
+ for the actions it receives — an action does not lose its auto-trigger
+ for having sorted past `action:bar`'s `maxVisible` (#4162). Executing
+ is the whole consumption: the dropdown is deliberately NOT opened, so
+ a transport flag never moves what the user sees. Rendered outside
+ `DropdownMenuContent` on purpose — see `ActionAutoTrigger`.
+
+ Placed after this renderer's early returns, which is the same rule
+ `action:bar` already follows: a container that renders nothing mounts
+ no children, so a hidden bar's inline `autoTrigger` button never fires
+ either. Container visibility governs mounting; the action's own
+ `visible` gate does not suppress the trigger (parity with
+ `action:button`, whose effect runs even when its gate renders null).
+ */}
+ {actions.map((action, index) => (
+
+ ))}
+
+
+
+
-
- {actions.map((action, index) => {
- // Render separator for actions tagged with 'separator-before'
- const showSeparator = action.tags?.includes('separator-before') && index > 0;
- return (
-
- {showSeparator && }
-
-
- );
- })}
-
-
+
+ {actions.map((action, index) => {
+ // Render separator for actions tagged with 'separator-before'
+ const showSeparator = action.tags?.includes('separator-before') && index > 0;
+ return (
+
+ {showSeparator && }
+
+
+ );
+ })}
+
+
+ >
);
},
);
diff --git a/packages/components/src/renderers/action/auto-trigger.ts b/packages/components/src/renderers/action/auto-trigger.ts
new file mode 100644
index 0000000000..86af9a62de
--- /dev/null
+++ b/packages/components/src/renderers/action/auto-trigger.ts
@@ -0,0 +1,84 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `autoTrigger` — the client-composed "run this action as soon as a renderer
+ * receives it" flag (#844), and the ONE implementation of its once-ness (#4162).
+ *
+ * ## What the flag means
+ *
+ * A caller (a welcome-page CTA that deep-links into "create", say) marks an
+ * action `autoTrigger: true` to have it run once on mount, through the exact
+ * same execute path as a click — so param dialogs, confirms and entitlement
+ * gates all still apply. It is NOT persisted metadata: the flag only ever
+ * exists on client-composed schemas, which makes hosts its only producers.
+ *
+ * ## Why this lives in a shared module (#4162)
+ *
+ * It was consumed by `action:button` alone. `action:bar` splits its post-gate
+ * list at `maxVisible` (3 desktop, 1 mobile) and hands the tail to
+ * `action:menu`, which had no `autoTrigger` handling — so an auto-triggered
+ * action that happened to sort past the threshold was rendered as an ordinary
+ * "More" menu entry and never ran, while the caller had already spent the
+ * one-shot signal it stood for (the #844 deep link is consumed by stripping it
+ * from the URL: measured `urlParam=null execute=0`, unrecoverable). Which
+ * actions lost their auto-trigger was a function of viewport width.
+ *
+ * The ruling on that card: **the flag's contract is "execute once on mount by
+ * whichever renderer receives the action"**. So every renderer that can receive
+ * an action consumes it — by EXECUTING, not by rendering an affordance and
+ * hoping — and they all consume it through this hook. Once-ness written twice
+ * is two behaviours waiting to drift apart, which is the same shape as the
+ * defect being fixed.
+ *
+ * ## The guard's exact semantics (unchanged from `action:button`'s original)
+ *
+ * A ref, not state: it must not re-render, and it must survive the identity
+ * churn of `schema` / `handleClick`, which change on every parent render. One
+ * ref per rendered action, for the lifetime of that action's component, so:
+ *
+ * - re-renders never re-fire it;
+ * - a flag that flips true LATER (a state-dependent toolbar attaches it once
+ * entitlements resolve) still fires exactly once, when it flips;
+ * - and it can never fire twice, whatever the flag does afterwards.
+ *
+ * Container visibility still governs mounting, and that is deliberate: a
+ * renderer that returns null before its children mount (an `action:bar` whose
+ * own `visible` is false, or a hidden `action:menu`) auto-triggers nothing,
+ * because nothing received the action. The action's OWN declared `visible`
+ * gate is a different question and does not suppress the trigger — in either
+ * renderer, measured: an `action:button` with `visible: false` renders nothing
+ * and still executes. That parity is what keeps a deep link from depending on
+ * where the bar happened to put the action.
+ */
+
+import { useEffect, useRef } from 'react';
+
+/**
+ * Is this action asking to be auto-triggered? One spelling of the test, so the
+ * flag cannot be read as `!== undefined` in one renderer and `=== true` in
+ * another. Deliberately strict: only the literal `true` arms it.
+ */
+export function hasAutoTrigger(action: unknown): boolean {
+ return (action as { autoTrigger?: unknown } | null | undefined)?.autoTrigger === true;
+}
+
+/**
+ * Run `run` at most once, as soon as `armed` is true.
+ *
+ * `run` may change identity freely (it is rebuilt from `schema` on most
+ * renders); the ref is what makes this once-only, so depending on it is safe.
+ */
+export function useAutoTriggerOnce(armed: boolean, run: () => void | Promise): void {
+ const fired = useRef(false);
+ useEffect(() => {
+ if (!armed || fired.current) return;
+ fired.current = true;
+ void run();
+ }, [armed, run]);
+}