Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/dedupe-script-action-error-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@object-ui/app-shell": patch
---

fix(app-shell): stop double-toasting failed script/modal action errors

`serverActionHandler` toasted the action error itself **and** returned
`{ success: false, error }`, which `ActionRunner.handlePostExecution` also
surfaces as a toast — so a failed script action (e.g. a validation throw)
showed two identical red toasts.

`apiHandler` and `flowHandler` already only return the error and let the
runner own the toast; `serverActionHandler` now does the same, so a failed
action toasts exactly once.
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,26 @@ vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));

// Spy on the toast library so we can assert the handlers DON'T toast errors
// themselves (the ActionRunner's post-execution hook owns the error toast —
// toasting here too double-fires it).
vi.mock('sonner', () => {
const fn: any = vi.fn();
fn.error = vi.fn();
fn.success = vi.fn();
return { toast: fn };
});

import { toast } from 'sonner';
import { useConsoleActionRuntime, ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
import { useAction, usePageVariables, PageVariablesProvider, PageVariableActionBridge } from '@object-ui/react';

beforeEach(() => {
authFetchSpy.mockReset();
navigateSpy.mockReset();
(toast as any).mockClear?.();
(toast as any).error.mockClear();
(toast as any).success.mockClear();
});

describe('useConsoleActionRuntime — authenticated handlers', () => {
Expand Down Expand Up @@ -170,6 +184,29 @@ describe('useConsoleActionRuntime — authenticated handlers', () => {
expect(res).toMatchObject({ success: true });
});

it('serverActionHandler returns a failed action error WITHOUT toasting it (the ActionRunner owns the error toast — no double toast)', async () => {
// A script action that throws (e.g. lead_apply_convert validation) returns
// { success:false, error } from the server. The handler must NOT toast it —
// ActionRunner.handlePostExecution does, and toasting here too showed the
// error twice (the reported bug).
authFetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ success: false, error: '线索信息不完整,提交转商机申请前请补全:终端客户' }),
});
const { result } = renderHook(() =>
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'mtc_lead' }),
);

let res: any;
await act(async () => {
res = await result.current.serverActionHandler({ type: 'script', name: 'lead_apply_convert' } as any);
});

expect(res).toEqual({ success: false, error: '线索信息不完整,提交转商机申请前请补全:终端客户' });
expect((toast as any).error).not.toHaveBeenCalled();
});

it('exposes ActionProvider props with the api/flow/script/modal handlers wired', () => {
const { result } = renderHook(() =>
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' }),
Expand Down
7 changes: 5 additions & 2 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,9 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
if (!res.ok || (json && json.success === false)) {
const errMsg = json?.error || `Action "${targetName}" failed (HTTP ${res.status})`;
if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } }
toast.error(errMsg);
// Don't toast here — the ActionRunner's post-execution hook surfaces
// `error` as a toast (see apiHandler/flowHandler, which likewise only
// return). Toasting here too double-fires the error (two identical toasts).
return { success: false, error: errMsg };
}
const shouldRefresh = action.refreshAfter !== false;
Expand Down Expand Up @@ -544,7 +546,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
} catch (error) {
if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } }
const msg = (error as Error).message;
toast.error(msg);
// The ActionRunner's post-execution hook toasts `error`; returning it here
// (without a local toast.error) avoids the double toast.
return { success: false, error: msg };
} finally {
serverActionInFlight.current.delete(inflightKey);
Expand Down
Loading