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]); +}