@remit/web-client 0.0.240 → 0.0.242

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,11 +5,9 @@
5
5
  * appointment prompt over an unrelated conflict. A 409 without one of these
6
6
  * codes is somebody else's error and keeps today's banner.
7
7
  */
8
- import type {
9
- ApiError,
10
- RemitImapCanonicalMailboxRole,
11
- } from "@remit/api-http-client/types.gen.ts";
8
+ import type { RemitImapCanonicalMailboxRole } from "@remit/api-http-client/types.gen.ts";
12
9
  import { CanonicalMailboxRole } from "@remit/domain-enums";
10
+ import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
13
11
 
14
12
  /** Why the role is unresolved, as the API's `details.reason` spells it. */
15
13
  export type FolderRoleRefusalReason = "none" | "stale" | "unconfirmed";
@@ -29,18 +27,8 @@ const REASONS: ReadonlySet<string> = new Set<FolderRoleRefusalReason>([
29
27
 
30
28
  const ROLES: ReadonlySet<string> = new Set(Object.values(CanonicalMailboxRole));
31
29
 
32
- /**
33
- * The wire body as `handleError` emits it — flat, so `code` and `details` sit
34
- * at the top level. Everything is re-checked at runtime: this is a network
35
- * boundary, and the type only says what the contract promises.
36
- */
37
- const bodyOf = (error: unknown): Partial<ApiError> | undefined =>
38
- typeof error === "object" && error !== null
39
- ? (error as Partial<ApiError>)
40
- : undefined;
41
-
42
30
  const stringAt = (
43
- details: ApiError["details"],
31
+ details: CodedApiErrorBody["details"],
44
32
  key: string,
45
33
  ): string | undefined => {
46
34
  const value = details?.[key];
@@ -55,10 +43,10 @@ const stringAt = (
55
43
  export const isFolderRoleRefusal = (
56
44
  error: unknown,
57
45
  ): FolderRoleRefusal | undefined => {
58
- const body = bodyOf(error);
46
+ const body = codedApiErrorBody(error);
59
47
  if (body?.code !== "folder_role_unresolved") return undefined;
60
48
  const { details } = body;
61
- if (typeof details !== "object" || details === null) return undefined;
49
+ if (!details) return undefined;
62
50
  const reason = stringAt(details, "reason");
63
51
  const role = stringAt(details, "role");
64
52
  const accountId = stringAt(details, "accountId");
@@ -78,4 +66,4 @@ export const isFolderRoleRefusal = (
78
66
  * one, retrying does not.
79
67
  */
80
68
  export const isMailboxNotSettledRefusal = (error: unknown): boolean =>
81
- bodyOf(error)?.code === "mailbox_not_settled";
69
+ codedApiErrorBody(error)?.code === "mailbox_not_settled";
@@ -1,15 +1,23 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { wrapHttpFailure } from "@/lib/api";
3
4
  import {
4
5
  isPlacementRefusal,
5
6
  placementRefusalBanner,
6
7
  } from "./placement-refusal.js";
7
8
 
8
- const refusal = (reason: string) => ({
9
- message: "Message 4f1 was not acted on: its folder and uid do not name...",
10
- code: "message_placement_unsettled",
11
- details: { accountId: "acc-1", messageId: "msg-1", reason },
12
- });
9
+ // The reader is handed what the error interceptor produces, not the flat wire
10
+ // body it carries at `.body` (#1004).
11
+ const refusal = (reason: string) =>
12
+ wrapHttpFailure(
13
+ {
14
+ message:
15
+ "Message 4f1 was not acted on: its folder and uid do not name...",
16
+ code: "message_placement_unsettled",
17
+ details: { accountId: "acc-1", messageId: "msg-1", reason },
18
+ },
19
+ 409,
20
+ );
13
21
 
14
22
  describe("isPlacementRefusal", () => {
15
23
  it("reads the reason and the message off the coded body", () => {
@@ -23,7 +31,7 @@ describe("isPlacementRefusal", () => {
23
31
  assert.equal(isPlacementRefusal(undefined), undefined);
24
32
  assert.equal(isPlacementRefusal(new Error("network")), undefined);
25
33
  assert.equal(
26
- isPlacementRefusal({ ...refusal("in_flight"), code: "other" }),
34
+ isPlacementRefusal(wrapHttpFailure({ code: "other" }, 409)),
27
35
  undefined,
28
36
  );
29
37
  assert.equal(isPlacementRefusal(refusal("something_else")), undefined);
@@ -4,8 +4,8 @@
4
4
  * server's sentence names a uuid and no remedy, and a message-string match
5
5
  * would start firing on an unrelated conflict the moment the copy changes.
6
6
  */
7
- import type { ApiError } from "@remit/api-http-client/types.gen.ts";
8
7
  import type { PushErrorInput } from "@/components/ui/error-banners";
8
+ import { type CodedApiErrorBody, codedApiErrorBody } from "@/lib/api";
9
9
 
10
10
  /**
11
11
  * `in_flight` clears on its own once the mail server confirms the move;
@@ -25,7 +25,7 @@ const REASONS: ReadonlySet<string> = new Set<PlacementRefusalReason>([
25
25
  ]);
26
26
 
27
27
  const stringAt = (
28
- details: ApiError["details"],
28
+ details: CodedApiErrorBody["details"],
29
29
  key: string,
30
30
  ): string | undefined => {
31
31
  const value = details?.[key];
@@ -35,11 +35,10 @@ const stringAt = (
35
35
  export const isPlacementRefusal = (
36
36
  error: unknown,
37
37
  ): PlacementRefusal | undefined => {
38
- if (typeof error !== "object" || error === null) return undefined;
39
- const body = error as Partial<ApiError>;
40
- if (body.code !== "message_placement_unsettled") return undefined;
38
+ const body = codedApiErrorBody(error);
39
+ if (body?.code !== "message_placement_unsettled") return undefined;
41
40
  const { details } = body;
42
- if (typeof details !== "object" || details === null) return undefined;
41
+ if (!details) return undefined;
43
42
  const reason = stringAt(details, "reason");
44
43
  const messageId = stringAt(details, "messageId");
45
44
  if (!reason || !REASONS.has(reason) || !messageId) return undefined;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * A coded 409 must reach the surface that answers it and nothing else (#1004).
3
+ *
4
+ * Both delete paths raise through `useMutation`, and the app wires the global
5
+ * fail-fast sink on the `MutationCache` (`shell/index.tsx`), where a 409 with
6
+ * no `meta` escalates to the full-screen fatal overlay. The prompt then opens
7
+ * underneath a red page nobody can see past — the refusal is handled and still
8
+ * never reaches the user. Every other test mounts on the harness's plain
9
+ * `QueryClient`, which has no such sink, so none of them can see this.
10
+ *
11
+ * Mounted here on the client the app actually builds, with the error
12
+ * interceptor registered.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import {
18
+ MutationCache,
19
+ QueryCache,
20
+ QueryClient,
21
+ QueryClientProvider,
22
+ } from "@tanstack/react-query";
23
+ import { act, createElement } from "react";
24
+ import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
25
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
26
+ import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
27
+ import {
28
+ handleMutationCacheError,
29
+ handleQueryCacheError,
30
+ } from "@/lib/query-error-handler";
31
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
32
+ import { type HttpMock, mockFetch } from "../test-support/http";
33
+ import { useDeleteMessages } from "./useDeleteMessages";
34
+ import { type EmptyTrashState, useEmptyTrash } from "./useEmptyTrash";
35
+ import "@/lib/client";
36
+
37
+ const ACCOUNT = "acc-1";
38
+ const INBOX = "mbx-inbox";
39
+ const TRASH = "mbx-trash";
40
+
41
+ let harness: DomHarness | undefined;
42
+ let http: HttpMock | undefined;
43
+ let deleteMessages: ((messageIds: string[]) => void) | undefined;
44
+ let trash: EmptyTrashState | undefined;
45
+
46
+ afterEach(() => {
47
+ harness?.close();
48
+ harness = undefined;
49
+ http?.restore();
50
+ http = undefined;
51
+ deleteMessages = undefined;
52
+ trash = undefined;
53
+ __resetFatalError();
54
+ });
55
+
56
+ const CONFIG = {
57
+ accounts: [
58
+ {
59
+ accountId: ACCOUNT,
60
+ email: "you@example.com",
61
+ folderAppointments: [
62
+ { role: "Trash", mailboxId: TRASH, source: "Proposed" },
63
+ ],
64
+ },
65
+ ],
66
+ };
67
+
68
+ /** Fresh every call — a `Response` body can only be read once. */
69
+ const refusal = (): Response =>
70
+ new Response(
71
+ JSON.stringify({
72
+ status: 409,
73
+ message: "Nobody has confirmed which folder is this account's Trash",
74
+ code: "folder_role_unresolved",
75
+ details: { reason: "unconfirmed", role: "Trash", accountId: ACCOUNT },
76
+ }),
77
+ { status: 409, headers: { "content-type": "application/json" } },
78
+ );
79
+
80
+ const broken = (): Response =>
81
+ new Response(JSON.stringify({ message: "boom" }), {
82
+ status: 500,
83
+ headers: { "content-type": "application/json" },
84
+ });
85
+
86
+ const Probe = () => {
87
+ deleteMessages = useDeleteMessages({
88
+ mailboxId: INBOX,
89
+ accountId: ACCOUNT,
90
+ }).deleteMessages;
91
+ trash = useEmptyTrash({ accountId: ACCOUNT, mailboxId: TRASH });
92
+ return null;
93
+ };
94
+
95
+ /** The client `shell/index.tsx` builds: the real global escalation sink. */
96
+ const appQueryClient = (): QueryClient =>
97
+ new QueryClient({
98
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
99
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
100
+ defaultOptions: {
101
+ queries: { retry: false },
102
+ mutations: { retry: false },
103
+ },
104
+ });
105
+
106
+ const settle = async (): Promise<void> => {
107
+ if (!harness) throw new Error("nothing mounted");
108
+ for (let round = 0; round < 40; round += 1) {
109
+ await harness.flush();
110
+ await harness.wait(0);
111
+ }
112
+ };
113
+
114
+ const mount = async (respond: (path: string) => unknown): Promise<void> => {
115
+ http = mockFetch((call) => respond(call.path));
116
+ harness = createDomHarness({ queryClient: appQueryClient() });
117
+ await harness.renderAsync(
118
+ createElement(
119
+ QueryClientProvider,
120
+ { client: harness.queryClient as never },
121
+ createElement(
122
+ ErrorBannerProvider,
123
+ null,
124
+ createElement(
125
+ RoleAppointmentPromptProvider,
126
+ null,
127
+ createElement(Probe),
128
+ ),
129
+ ),
130
+ ),
131
+ );
132
+ await settle();
133
+ };
134
+
135
+ const responder =
136
+ (endpoint: string, answer: () => Response) =>
137
+ (path: string): unknown => {
138
+ if (path.endsWith("/config")) return CONFIG;
139
+ if (path.endsWith(endpoint)) return answer();
140
+ return {};
141
+ };
142
+
143
+ describe("a delete refused for its folder role", () => {
144
+ it("opens the appointment prompt without taking the screen", async () => {
145
+ await mount(responder("/messages/delete", refusal));
146
+
147
+ await act(async () => {
148
+ deleteMessages?.(["msg-1"]);
149
+ });
150
+ await settle();
151
+
152
+ assert.match(harness?.text() ?? "", /Confirm this account's Trash folder/);
153
+ assert.equal(
154
+ getCurrentFatalError(),
155
+ null,
156
+ "a refusal the prompt answers is not a fatal error",
157
+ );
158
+ });
159
+
160
+ it("still escalates a 500 on the same delete", async () => {
161
+ await mount(responder("/messages/delete", broken));
162
+
163
+ await act(async () => {
164
+ deleteMessages?.(["msg-1"]);
165
+ });
166
+ await settle();
167
+
168
+ assert.ok(
169
+ getCurrentFatalError(),
170
+ "the 409 opt-out must not cover anything else",
171
+ );
172
+ });
173
+ });
174
+
175
+ describe("an empty Trash refused for its folder role", () => {
176
+ it("states the refusal in the pane without taking the screen", async () => {
177
+ await mount(responder("/trash/empty", refusal));
178
+
179
+ await act(async () => {
180
+ trash?.emptyTrash();
181
+ });
182
+ await settle();
183
+
184
+ assert.equal(trash?.refusal?.reason, "unconfirmed");
185
+ assert.equal(getCurrentFatalError(), null);
186
+ });
187
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Coming back to a tab always leaves the poll loop with a next tick (#1031).
3
+ *
4
+ * A tick that fires while the tab is hidden deliberately does not reschedule —
5
+ * `visibilitychange` is the only thing that resumes the loop. While that resume
6
+ * was itself conditional on a poll being due, the resume could be skipped for
7
+ * good: press refresh (which opens the hot 30s window), hide the tab shortly
8
+ * after a hot tick, let the window lapse while away, and the return is measured
9
+ * against the ambient interval the tab never slept through. Nothing re-armed,
10
+ * and the tab stopped polling until the account list changed or the layout
11
+ * remounted.
12
+ *
13
+ * At the default the ambient and hot intervals are equal and the loop is safe
14
+ * either way, so these drive a deployment that lengthened
15
+ * `mailboxPollIntervalSeconds` — the only shape where the bug is reachable.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { afterEach, beforeEach, describe, mock, test } from "node:test";
20
+ import { QueryClientProvider } from "@tanstack/react-query";
21
+ import { act, createElement } from "react";
22
+ import {
23
+ __resetHotSyncWindow,
24
+ HOT_POLL_INTERVAL_MS,
25
+ HOT_WINDOW_MS,
26
+ startHotSyncWindow,
27
+ } from "@/lib/hot-sync-window";
28
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
29
+ import { makeAccount } from "@/test-support/fixtures";
30
+ import { type HttpMock, mockFetch } from "@/test-support/http";
31
+
32
+ const ACCOUNT_ID = "acc-1";
33
+ const AMBIENT_SECONDS = 600;
34
+ const AMBIENT_MS = AMBIENT_SECONDS * 1000;
35
+ const T0 = 1_700_000_000_000;
36
+
37
+ // The hook resolves the ambient interval once, at module load, so the config has
38
+ // to be in place before it is imported.
39
+ globalThis.__REMIT_CONFIG__ = {
40
+ mailboxPollIntervalSeconds: String(AMBIENT_SECONDS),
41
+ };
42
+ const { __resetStaleAccountSyncGuard, POLL_INTERVAL_MS, useStaleAccountSync } =
43
+ await import("./useStaleAccountSync.js");
44
+
45
+ let harness: DomHarness | undefined;
46
+ let http: HttpMock | undefined;
47
+
48
+ function PollLoop() {
49
+ useStaleAccountSync([makeAccount({ accountId: ACCOUNT_ID, lastSyncAt: T0 })]);
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * jsdom reports "prerender" unless it is pretending to be visual, and the
55
+ * shared environment deliberately does not — so a spec about a tab being looked
56
+ * at has to say so.
57
+ */
58
+ const setVisibility = (state: "hidden" | "visible"): void => {
59
+ Object.defineProperty(document, "visibilityState", {
60
+ configurable: true,
61
+ get: () => state,
62
+ });
63
+ };
64
+
65
+ /** Enough microtask turns for a dispatched poll to reach the fetch seam. */
66
+ const settle = async (dom: DomHarness): Promise<void> => {
67
+ for (let round = 0; round < 3; round += 1) await dom.flush();
68
+ };
69
+
70
+ const mount = async (): Promise<DomHarness> => {
71
+ http = mockFetch(() => ({ status: "queued" }));
72
+ const dom = createDomHarness();
73
+ harness = dom;
74
+ dom.render(
75
+ createElement(
76
+ QueryClientProvider,
77
+ { client: dom.queryClient },
78
+ createElement(PollLoop),
79
+ ),
80
+ );
81
+ await settle(dom);
82
+ return dom;
83
+ };
84
+
85
+ const advance = async (dom: DomHarness, ms: number): Promise<void> => {
86
+ await act(async () => {
87
+ mock.timers.tick(ms);
88
+ });
89
+ await settle(dom);
90
+ };
91
+
92
+ const goVisible = async (dom: DomHarness): Promise<void> => {
93
+ setVisibility("visible");
94
+ dom.dispatch(dom.document, new Event("visibilitychange"));
95
+ await settle(dom);
96
+ };
97
+
98
+ const goHidden = async (dom: DomHarness): Promise<void> => {
99
+ setVisibility("hidden");
100
+ dom.dispatch(dom.document, new Event("visibilitychange"));
101
+ await settle(dom);
102
+ };
103
+
104
+ /** Every background sync this tab has fired since it mounted. */
105
+ const polls = (): number => http?.to(`/${ACCOUNT_ID}/sync`).length ?? 0;
106
+
107
+ describe("the poll loop across hide and show, on a long ambient interval", () => {
108
+ beforeEach(() => {
109
+ mock.timers.enable({ apis: ["setTimeout", "Date"], now: T0 });
110
+ });
111
+
112
+ afterEach(() => {
113
+ mock.timers.reset();
114
+ harness?.close();
115
+ harness = undefined;
116
+ http?.restore();
117
+ http = undefined;
118
+ __resetHotSyncWindow();
119
+ __resetStaleAccountSyncGuard();
120
+ Reflect.deleteProperty(document, "visibilityState");
121
+ });
122
+
123
+ test("the deployment under test really is on a lengthened interval", () => {
124
+ assert.equal(POLL_INTERVAL_MS, AMBIENT_MS);
125
+ });
126
+
127
+ test("keeps polling after a refresh press, a hidden hot window and a return", async () => {
128
+ setVisibility("visible");
129
+ const dom = await mount();
130
+
131
+ // The press: the hot window opens and the loop drops onto its cadence.
132
+ startHotSyncWindow();
133
+ await advance(dom, HOT_POLL_INTERVAL_MS);
134
+ assert.equal(polls(), 1, "the hot cadence polled once");
135
+
136
+ // Away before the next hot tick, and back only after the window lapsed.
137
+ await goHidden(dom);
138
+ await advance(dom, HOT_WINDOW_MS);
139
+ assert.equal(polls(), 1, "a hidden tab polled");
140
+
141
+ await goVisible(dom);
142
+ assert.equal(
143
+ polls(),
144
+ 1,
145
+ "the return polled a tab that is not due on the ambient interval",
146
+ );
147
+
148
+ await advance(dom, AMBIENT_MS);
149
+ assert.equal(polls(), 2, "the tab came back with no tick scheduled");
150
+ });
151
+
152
+ test("polls on return once the ambient interval has passed", async () => {
153
+ setVisibility("visible");
154
+ const dom = await mount();
155
+
156
+ await goHidden(dom);
157
+ await advance(dom, AMBIENT_MS);
158
+ assert.equal(polls(), 0, "a hidden tab polled");
159
+
160
+ await goVisible(dom);
161
+ assert.equal(polls(), 1, "a tab due a poll did not catch up on return");
162
+
163
+ await advance(dom, AMBIENT_MS);
164
+ assert.equal(polls(), 2, "the catch-up left no next tick behind it");
165
+ });
166
+ });
@@ -558,6 +558,110 @@ describe("useSystemUpdate — actions", () => {
558
558
  }
559
559
  });
560
560
 
561
+ test("checking from the success row reaches the pane, not a dead button (#1015)", async () => {
562
+ // An undismissed run outcome wins over any check in flight, so a press
563
+ // made while the success banner holds the pane would leave the screen
564
+ // exactly as it was. The row's control retires the outcome first.
565
+ const succeeded = {
566
+ currentVersion: "0.9.4",
567
+ check: { status: "ok", updateAvailable: false },
568
+ run: run({ outcome: "succeeded" }),
569
+ };
570
+ const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
571
+ await settle(dom);
572
+ assert.match(dom.html(), /Updated to Remit 0\.9\.4/);
573
+
574
+ dom.click(dom.byText("button", "Check for updates"));
575
+ await waitFor(
576
+ dom,
577
+ () => /Looking for a newer version/.test(dom.html()),
578
+ "the pressed check to reach the pane",
579
+ );
580
+ assert.doesNotMatch(dom.html(), /Updated to Remit/);
581
+ });
582
+
583
+ test("a check pressed from the success row fails in the open, not behind the banner (#1015)", async () => {
584
+ const succeeded = {
585
+ currentVersion: "0.9.4",
586
+ check: { status: "ok", updateAvailable: false },
587
+ run: run({ outcome: "succeeded" }),
588
+ };
589
+ const { dom } = mountApi(() => succeeded, createElement(SelfUpdatePanel));
590
+ await settle(dom);
591
+
592
+ const control = dom.byText("button", "Check for updates");
593
+ const realNow = Date.now;
594
+ try {
595
+ await act(async () => {
596
+ // Dispatched inside this `act` rather than through `dom.click`, so
597
+ // the press is stamped off the real clock and the wait it arms is
598
+ // measured against one already past the budget.
599
+ control.dispatchEvent(
600
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
601
+ );
602
+ Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
603
+ await dom.flush();
604
+ });
605
+ await dom.wait(5);
606
+ await dom.flush();
607
+
608
+ assert.match(dom.html(), /The updater did not answer/);
609
+ assert.doesNotMatch(dom.html(), /Updated to Remit/);
610
+ } finally {
611
+ Date.now = realNow;
612
+ }
613
+ });
614
+
615
+ test("dismissing an outcome does not uncover the check verdict it hid", async () => {
616
+ // The failure belongs to a press made before the run, on a pane the user
617
+ // has not been able to see since. Serving it as the current verdict claims
618
+ // a check failed that may well have succeeded.
619
+ let response: unknown = available;
620
+ const { dom, api } = mountApi(
621
+ () => response,
622
+ createElement(SelfUpdatePanel),
623
+ );
624
+ await settle(dom);
625
+
626
+ const realNow = Date.now;
627
+ try {
628
+ await act(async () => {
629
+ api().onCheck();
630
+ Date.now = () => realNow() + CHECK_ANSWER_BUDGET_MS + 1_000;
631
+ await dom.flush();
632
+ });
633
+ await dom.wait(5);
634
+ await dom.flush();
635
+ assert.match(dom.html(), /The updater did not answer/);
636
+ } finally {
637
+ Date.now = realNow;
638
+ }
639
+
640
+ response = {
641
+ currentVersion: "0.9.4",
642
+ check: { status: "ok", updateAvailable: false },
643
+ run: run({ outcome: "succeeded" }),
644
+ };
645
+ await retryConnection(dom, api);
646
+ await waitFor(
647
+ dom,
648
+ () => /Updated to Remit 0\.9\.4/.test(dom.html()),
649
+ "the finished run to reach the pane",
650
+ );
651
+
652
+ await act(async () => {
653
+ api().onDismissResult();
654
+ await dom.flush();
655
+ });
656
+ await waitFor(
657
+ dom,
658
+ () => !/Updated to Remit/.test(dom.html()),
659
+ "the outcome to be retired",
660
+ );
661
+ assert.doesNotMatch(dom.html(), /The updater did not answer/);
662
+ assert.match(dom.html(), /0\.9\.4 is the latest version/);
663
+ });
664
+
561
665
  test("a refused refresh is shown, never swallowed back into the old verdict (#599)", async () => {
562
666
  let refused = false;
563
667
  const { dom, api } = mountApi(
@@ -249,6 +249,12 @@ export function useSystemUpdate(): SelfUpdateApi {
249
249
  if (shownRunIdRef.current !== null)
250
250
  setDismissedRunId(shownRunIdRef.current);
251
251
  setHeld(null);
252
+ // Retiring the outcome uncovers the check verdict beneath it. A failure
253
+ // recorded while the outcome held the pane describes a press the user can
254
+ // no longer see, and an update run of its own supersedes anything an
255
+ // earlier check concluded — either way it must not resurface here.
256
+ setCheckPress(null);
257
+ setCheckFailure(null);
252
258
  }, []);
253
259
 
254
260
  return {
@@ -16,6 +16,7 @@ import {
16
16
  } from "@/components/ui/placement-refusal";
17
17
  import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
18
18
  import { runChunkedMutation } from "@/lib/bulk-actions";
19
+ import { softErrorStatuses } from "@/lib/error-classifier";
19
20
  import {
20
21
  cancelThreadListQueries,
21
22
  invalidateThreadListQueries,
@@ -92,6 +93,10 @@ export const useDeleteMessages = ({
92
93
 
93
94
  const { mutateAsync, isPending } = useMutation({
94
95
  ...messageBulkOperationsDeleteMessagesMutation(),
96
+ // A coded 409 is answered here — the prompt, or a banner — so it must not
97
+ // also take the whole screen. Only 409: a 401, 403 or 5xx on a delete is
98
+ // still the fatal page's (#1059).
99
+ meta: softErrorStatuses(409),
95
100
  onMutate: async (variables): Promise<ThreadMutationContext> => {
96
101
  const messageIds = new Set(variables.body.messageIds ?? []);
97
102
 
@@ -22,6 +22,7 @@ import {
22
22
  type FolderRoleRefusal,
23
23
  isFolderRoleRefusal,
24
24
  } from "@/components/ui/folder-role-refusal";
25
+ import { softErrorStatuses } from "@/lib/error-classifier";
25
26
  import {
26
27
  invalidateThreadListQueries,
27
28
  threadListCacheKeys,
@@ -109,6 +110,9 @@ export const useEmptyTrash = ({
109
110
 
110
111
  const { mutateAsync, isPending } = useMutation({
111
112
  ...trashOperationsEmptyTrashMutation(),
113
+ // The refusal is rendered in the pane, so it must not also take the whole
114
+ // screen. Only 409 — every other status is still the fatal page's (#1059).
115
+ meta: softErrorStatuses(409),
112
116
  onMutate: (): EmptyTrashContext => {
113
117
  inFlight.current = scope;
114
118
  return { scope, listMailboxId: listMailboxIdNow() };
@@ -351,6 +351,12 @@ export const useMessageBodyContent = ({
351
351
 
352
352
  const isLoading = query.isLoading && !!picked;
353
353
 
354
+ // `picked` is null for two different reasons, and a caller that treats them
355
+ // alike is wrong about one of them: the message has not arrived yet, or it
356
+ // arrived carrying nothing renderable. Only the second is a fact about the
357
+ // message, and only it may be stated to the reader (#1030).
358
+ const hasNoRenderablePart = bodyParts !== undefined && picked === null;
359
+
354
360
  useEffect(() => {
355
361
  if (isLoading) {
356
362
  loadStartRef.current = performance.now();
@@ -374,6 +380,7 @@ export const useMessageBodyContent = ({
374
380
  picked,
375
381
  data,
376
382
  isLoading,
383
+ hasNoRenderablePart,
377
384
  isError: query.isError,
378
385
  error: query.error,
379
386
  refetch: query.refetch,