Skip to content

Commit 4db7eb3

Browse files
authored
fix(actions): a failed server action no longer reports as success (green toast) (#2963)
objectstack#3913. useConsoleActionRuntime.serverActionHandler — the console's main action path (list toolbars, row actions, page actions) — decided success from res.ok and the OUTER envelope only. A server that reports a handler failure as HTTP 200 with the failure nested one level down passes both guards, so the ActionRunner fired its green "completed" toast, the list refreshed, and the real error was swallowed. RecordDetailView's copy of the handler already inspected the inner envelope; the shared runtime now does too, as does marketplaceApi.installPackage, which had the identical hole and could report a package as installed when it was not. Also: the failure body can be {success:false, error:{message, code}}. RecordDetailView read `json?.error` raw and would have handed that OBJECT to toast.error() as a React child, crashing the page (React #31) — the exact failure the runtime's errorDetail helper existed to prevent. That helper is now a shared util (utils/actionErrorDetail) and both call sites use it.
1 parent f8a95e5 commit 4db7eb3

6 files changed

Lines changed: 182 additions & 23 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(actions): a failed server action no longer reports as success (green toast) — objectstack#3913
6+
7+
`useConsoleActionRuntime.serverActionHandler` — the console's **main** action
8+
path (list toolbars, row actions, page actions) — decided success from
9+
`res.ok` and the OUTER envelope only:
10+
11+
```ts
12+
if (!res.ok || (json && json.success === false)) { /* failure */ }
13+
```
14+
15+
A server older than objectstack#3913 reports a handler failure as HTTP **200**
16+
with the failure nested one level down:
17+
18+
```json
19+
{"success":true,"data":{"success":false,"error":"Action 'log_call' on object '*' not found"}}
20+
```
21+
22+
Both guards pass, so the action was reported as completed: the ActionRunner
23+
fired its green "completed" toast, the list refreshed, and the real error was
24+
swallowed. `RecordDetailView`'s copy of the same handler already inspected the
25+
inner envelope; the shared runtime now does too, and the marketplace install
26+
call (`marketplaceApi.installPackage`), which had the identical hole and could
27+
report a package as installed when it was not.
28+
29+
Current servers answer a failed action with a real HTTP status, which `!res.ok`
30+
catches first — the inner-envelope check is what keeps the console honest
31+
against a runtime that has not been upgraded yet.
32+
33+
**Also fixed:** with objectstack#3913 the failure body is
34+
`{success: false, error: {message, code}}`. `RecordDetailView` read `json?.error`
35+
raw and would have handed that **object** to `toast.error()` as a React child,
36+
crashing the page (React #31) — the exact failure the console runtime's
37+
`errorDetail` helper existed to prevent. That helper is now a shared util
38+
(`utils/actionErrorDetail`) and both call sites go through it, so a nested
39+
`{message}` always resolves to a string.

packages/app-shell/src/console/marketplace/marketplaceApi.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,9 +248,20 @@ export async function installPackage(input: {
248248
});
249249
let payload: any = null;
250250
try { payload = await res.json(); } catch { /* empty */ }
251-
if (!res.ok) {
251+
// A server older than objectstack#3913 reports a failed install action as
252+
// HTTP 200 `{success: true, data: {success: false, error}}` — checking only
253+
// `res.ok` reported a package as installed when it was not. Current servers
254+
// answer with a real status, which `!res.ok` catches first.
255+
const inner = payload?.data;
256+
const innerFailed = !!inner && typeof inner === 'object' && inner.success === false;
257+
if (!res.ok || innerFailed) {
252258
const code = payload?.code ?? payload?.error?.code ?? `HTTP_${res.status}`;
253-
const message = payload?.error ?? payload?.message ?? res.statusText;
259+
// `error` is a nested `{code, message}` object on the current wire and a
260+
// plain string on the older one — read the message out of both before
261+
// falling back, or a real explanation degrades into a bare status code.
262+
const message = innerFailed
263+
? (inner.error ?? res.statusText)
264+
: (payload?.error?.message ?? payload?.error ?? payload?.message ?? res.statusText);
254265
const err = new Error(typeof message === 'string' ? message : `${code}`);
255266
(err as any).code = code;
256267
(err as any).status = res.status;

packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,79 @@ describe('useConsoleActionRuntime — authenticated handlers', () => {
305305
expect((toast as any).error).not.toHaveBeenCalled();
306306
});
307307

308+
it('serverActionHandler treats an INNER success:false as a failure (objectstack#3913 — no green toast on a failed action)', async () => {
309+
// A server older than objectstack#3913 wraps a handler failure as HTTP 200
310+
// `{success: true, data: {success: false, error}}`. Reading only `res.ok`
311+
// and the OUTER `success` reported that as a completed action and fired the
312+
// green "completed" toast while swallowing the real error — the reported
313+
// bug. The console must inspect the inner envelope for those servers.
314+
authFetchSpy.mockResolvedValue({
315+
ok: true,
316+
status: 200,
317+
json: async () => ({
318+
success: true,
319+
data: { success: false, error: "Action 'log_call' on object '*' not found" },
320+
}),
321+
});
322+
const { result } = renderHook(() =>
323+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
324+
);
325+
326+
let res: any;
327+
await act(async () => {
328+
res = await result.current.serverActionHandler({ type: 'script', name: 'log_call' } as any);
329+
});
330+
331+
expect(res).toEqual({ success: false, error: "Action 'log_call' on object '*' not found" });
332+
expect((toast as any).error).not.toHaveBeenCalled();
333+
});
334+
335+
it('serverActionHandler resolves a nested {error:{message}} to a STRING (objectstack#3913 wire)', async () => {
336+
// Current servers answer a failed action with a real status and the nested
337+
// envelope. Passing that object through as `ActionResult.error` reaches
338+
// `toast.error()` as a React child and crashes the page (React #31).
339+
authFetchSpy.mockResolvedValue({
340+
ok: false,
341+
status: 404,
342+
json: async () => ({
343+
success: false,
344+
error: { message: "Action 'log_call' on object 'global' not found", code: 404 },
345+
}),
346+
});
347+
const { result } = renderHook(() =>
348+
useConsoleActionRuntime({ dataSource: {}, objects: [] }),
349+
);
350+
351+
let res: any;
352+
await act(async () => {
353+
res = await result.current.serverActionHandler({ type: 'script', name: 'log_call' } as any);
354+
});
355+
356+
expect(res.success).toBe(false);
357+
expect(typeof res.error).toBe('string');
358+
expect(res.error).toBe("Action 'log_call' on object 'global' not found");
359+
});
360+
361+
it('serverActionHandler still reports success when the inner envelope says so', async () => {
362+
// The success wire is unchanged by objectstack#3913 — guard against the
363+
// inner-envelope check turning a good action into a failure.
364+
authFetchSpy.mockResolvedValue({
365+
ok: true,
366+
status: 200,
367+
json: async () => ({ success: true, data: { success: true, data: { id: 'call_1' } } }),
368+
});
369+
const { result } = renderHook(() =>
370+
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'crm_call' }),
371+
);
372+
373+
let res: any;
374+
await act(async () => {
375+
res = await result.current.serverActionHandler({ type: 'script', name: 'log_call' } as any);
376+
});
377+
378+
expect(res).toMatchObject({ success: true });
379+
});
380+
308381
it('exposes ActionProvider props with the api/flow/script/modal handlers wired', () => {
309382
const { result } = renderHook(() =>
310383
useConsoleActionRuntime({ dataSource: {}, objects: [], objectName: 'inv' }),

packages/app-shell/src/hooks/useConsoleActionRuntime.tsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { resolveActionParams } from '../utils/resolveActionParams';
4848
import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../environment/EnvironmentEntitlementDialog';
4949
import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements';
5050
import { resolvePageVarTokens } from '../utils/resolvePageVarTokens';
51+
import { actionErrorDetail } from '../utils/actionErrorDetail';
5152

5253
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
5354

@@ -68,20 +69,11 @@ function isRecordScoped(action: ActionDef): boolean {
6869
}
6970

7071
/**
71-
* Extract a human-readable message from an error response body. The
72-
* ObjectStack envelope nests it as `{ error: { code, message } }` — passing
73-
* that object through as `ActionResult.error` reaches `toast.error()` as a
74-
* React child and crashes the page (React #31). Always resolve to a string.
72+
* Extract a human-readable message from an error response body — shared with
73+
* `RecordDetailView`, which runs the same `/actions` request and needs the same
74+
* React-#31 guard. See `../utils/actionErrorDetail`.
7575
*/
76-
function errorDetail(body: unknown, fallback: string): string {
77-
const b = body as { error?: unknown; message?: unknown } | null;
78-
const err = b?.error;
79-
if (typeof err === 'string' && err.length > 0) return err;
80-
const nested = (err as { message?: unknown } | null)?.message;
81-
if (typeof nested === 'string' && nested.length > 0) return nested;
82-
if (typeof b?.message === 'string' && b.message.length > 0) return b.message;
83-
return fallback;
84-
}
76+
const errorDetail = actionErrorDetail;
8577

8678
export interface ConsoleActionRuntimeOptions {
8779
/** Adapter for generic CRUD / execute calls. */
@@ -595,8 +587,21 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
595587
},
596588
);
597589
const json = await res.json().catch(() => null);
598-
if (!res.ok || (json && json.success === false)) {
599-
const errMsg = errorDetail(json, `Action "${targetName}" failed (HTTP ${res.status})`);
590+
// A server older than objectstack#3913 reports a script action that THREW
591+
// as HTTP 200 `{success: true, data: {success: false, error}}` — transport
592+
// success wrapping a business failure. Reading only `res.ok` and the OUTER
593+
// `success` mistook that for a completed action and fired the green
594+
// "completed" toast while swallowing the real error. Inspect the INNER
595+
// envelope too (RecordDetailView's copy of this handler already did).
596+
// Current servers answer with a real status, so `!res.ok` catches it
597+
// first; this branch is what keeps the console honest against a server
598+
// that has not been upgraded yet.
599+
const inner = json?.data;
600+
const innerFailed = !!inner && typeof inner === 'object' && (inner as any).success === false;
601+
if (!res.ok || (json && json.success === false) || innerFailed) {
602+
const errMsg = innerFailed
603+
? errorDetail(inner, `Action "${targetName}" failed`)
604+
: errorDetail(json, `Action "${targetName}" failed (HTTP ${res.status})`);
600605
if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } }
601606
// Don't toast here — the ActionRunner's post-execution hook surfaces
602607
// `error` as a toast (see apiHandler/flowHandler, which likewise only
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Resolve a human-readable message out of an ObjectStack error payload.
3+
*
4+
* Always returns a STRING. The envelope nests the message as
5+
* `{ error: { code, message } }`, and passing that object through as
6+
* `ActionResult.error` reaches `toast.error()` as a React child and crashes the
7+
* page (React #31). That shape used to be rare on the `/actions` route —
8+
* failures came back as HTTP 200 with a plain-string `error` — but since
9+
* objectstack#3913 a failed action answers with a real status and the nested
10+
* object, so every `/actions` caller has to go through this.
11+
*
12+
* Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`,
13+
* `{message: 'msg'}`, else the caller's fallback.
14+
*/
15+
export function actionErrorDetail(body: unknown, fallback: string): string {
16+
const b = body as { error?: unknown; message?: unknown } | null;
17+
const err = b?.error;
18+
if (typeof err === 'string' && err.length > 0) return err;
19+
const nested = (err as { message?: unknown } | null)?.message;
20+
if (typeof nested === 'string' && nested.length > 0) return nested;
21+
if (typeof b?.message === 'string' && b.message.length > 0) return b.message;
22+
return fallback;
23+
}

packages/app-shell/src/views/RecordDetailView.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { RelatedRecordActionsBridge } from './RelatedRecordActionsBridge';
3333
import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync';
3434
import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams';
3535
import { resolveActionParams } from '../utils/resolveActionParams';
36+
import { actionErrorDetail } from '../utils/actionErrorDetail';
3637
import { useRecordBreadcrumbTitle } from '../context/NavigationContext';
3738
import type { FeedItem } from '@object-ui/types';
3839
import type { ActionDef, ActionParamDef } from '@object-ui/core';
@@ -759,15 +760,22 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
759760
);
760761
const json = await res.json().catch(() => null);
761762
// The action route wraps the handler's return value in a {success, data}
762-
// envelope. A script action that THROWS is reported as
763-
// `data: { success: false, error }` while the OUTER success stays true,
764-
// so we must inspect the inner envelope too — otherwise a failed action
765-
// is mistaken for success and fires the green "completed" toast while the
766-
// real error is swallowed.
763+
// envelope. A server older than objectstack#3913 reports a script action
764+
// that THROWS as `data: { success: false, error }` while the OUTER success
765+
// stays true, so we must inspect the inner envelope too — otherwise a
766+
// failed action is mistaken for success and fires the green "completed"
767+
// toast while the real error is swallowed. Current servers answer with a
768+
// real status, which `!res.ok` catches first.
767769
const inner = json?.data;
768770
const innerFailed = inner && typeof inner === 'object' && inner.success === false;
769771
if (!res.ok || (json && json.success === false) || innerFailed) {
770-
const errMsg = (innerFailed && inner.error) || json?.error || `Action "${targetName}" failed (HTTP ${res.status})`;
772+
// Always resolve to a STRING. Since objectstack#3913 a failed action
773+
// answers with the nested `{error: {code, message}}` envelope — handing
774+
// that object to `toast.error()` as a React child crashes the page
775+
// (React #31), which the raw `json?.error` read here used to do.
776+
const errMsg = innerFailed
777+
? actionErrorDetail(inner, `Action "${targetName}" failed`)
778+
: actionErrorDetail(json, `Action "${targetName}" failed (HTTP ${res.status})`);
771779
if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } }
772780
// Don't toast here. This handler always runs through the ActionRunner
773781
// (registered as the `script` handler on the ActionProvider below, which

0 commit comments

Comments
 (0)