@remit/web-client 0.0.269 → 0.0.271
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.
- package/package.json +1 -1
- package/src/components/mail/MessageList.run-reporting.test.ts +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +15 -50
- package/src/components/mail/bulk-run-state.test.ts +100 -0
- package/src/components/mail/bulk-run-state.ts +99 -0
- package/src/hooks/escalated-run-lifetime.render.test.ts +87 -6
- package/src/hooks/update-restart-escalation.render.test.ts +340 -0
- package/src/hooks/use-system-update.ts +49 -2
- package/src/hooks/useEscalatedActions.ts +45 -24
- package/src/lib/bulk-action-copy.test.ts +14 -4
- package/src/lib/bulk-action-copy.ts +5 -3
- package/src/lib/bulk-actions.test.ts +115 -26
- package/src/lib/bulk-actions.ts +40 -10
- package/src/lib/error-classifier.test.ts +59 -0
- package/src/lib/error-classifier.ts +48 -2
- package/src/lib/self-update-state.test.ts +1 -0
- package/src/lib/self-update-state.ts +138 -30
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The update poll against the app's own fail-fast sink (#468).
|
|
3
|
+
*
|
|
4
|
+
* An update stops and starts the backend on purpose, so the proxy in front of
|
|
5
|
+
* it answers 502 for the seconds it is gone. Every other test in this package
|
|
6
|
+
* mounts on the harness's plain `QueryClient`, which has no global sink, so none
|
|
7
|
+
* of them can see that the 502 took the whole screen before the update surface
|
|
8
|
+
* ever read it. Mounted here on the client `shell/index.tsx` builds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, test } from "node:test";
|
|
13
|
+
import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
|
|
14
|
+
import { act, createElement } from "react";
|
|
15
|
+
import { SelfUpdateOverlay } from "@/components/self-update/SelfUpdateOverlay";
|
|
16
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
17
|
+
import {
|
|
18
|
+
handleMutationCacheError,
|
|
19
|
+
handleQueryCacheError,
|
|
20
|
+
} from "@/lib/query-error-handler";
|
|
21
|
+
import {
|
|
22
|
+
APPLY_BUDGET_SECONDS,
|
|
23
|
+
NEVER_CAME_BACK_MARGIN_SECONDS,
|
|
24
|
+
} from "@/lib/self-update-state";
|
|
25
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
26
|
+
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
27
|
+
import {
|
|
28
|
+
type SelfUpdateApi,
|
|
29
|
+
SelfUpdateProvider,
|
|
30
|
+
useSelfUpdate,
|
|
31
|
+
} from "./use-system-update";
|
|
32
|
+
import "@/lib/client";
|
|
33
|
+
|
|
34
|
+
const BUDGET_MS =
|
|
35
|
+
(APPLY_BUDGET_SECONDS + NEVER_CAME_BACK_MARGIN_SECONDS) * 1000;
|
|
36
|
+
|
|
37
|
+
let harness: DomHarness | undefined;
|
|
38
|
+
let http: HttpMock | undefined;
|
|
39
|
+
let api: SelfUpdateApi | undefined;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
__resetFatalError();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
__resetFatalError();
|
|
47
|
+
http?.restore();
|
|
48
|
+
http = undefined;
|
|
49
|
+
harness?.close();
|
|
50
|
+
harness = undefined;
|
|
51
|
+
api = undefined;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const available = {
|
|
55
|
+
currentVersion: "0.9.3",
|
|
56
|
+
check: {
|
|
57
|
+
status: "ok",
|
|
58
|
+
updateAvailable: true,
|
|
59
|
+
latestVersion: "0.9.4",
|
|
60
|
+
publishedAt: "2026-07-14T09:00:00.000Z",
|
|
61
|
+
summary: "Faster first sync.",
|
|
62
|
+
releaseNotesUrl: "https://example.test/notes",
|
|
63
|
+
},
|
|
64
|
+
run: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const accepted = {
|
|
68
|
+
currentVersion: "0.9.3",
|
|
69
|
+
check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
|
|
70
|
+
run: {
|
|
71
|
+
runId: "upd_1",
|
|
72
|
+
fromVersion: "0.9.3",
|
|
73
|
+
targetVersion: "0.9.4",
|
|
74
|
+
phase: "stopping",
|
|
75
|
+
outcome: null,
|
|
76
|
+
startedAt: "2026-07-20T11:59:30.000Z",
|
|
77
|
+
updatedAt: "2026-07-20T11:59:45.000Z",
|
|
78
|
+
message: "Restarting Remit on 0.9.4.",
|
|
79
|
+
logCommand: "remit logs --since 10m",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The run as a tab that never pressed install finds it: reported by the server,
|
|
85
|
+
* started just now, so the wait is measured from a start the page did not see.
|
|
86
|
+
*/
|
|
87
|
+
const reportedRunning = () => ({
|
|
88
|
+
...accepted,
|
|
89
|
+
run: { ...accepted.run, startedAt: new Date().toISOString() },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The client `shell/index.tsx` builds: the real global escalation sink. */
|
|
93
|
+
const appQueryClient = (): QueryClient =>
|
|
94
|
+
new QueryClient({
|
|
95
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
96
|
+
mutationCache: new MutationCache({ onError: handleMutationCacheError }),
|
|
97
|
+
defaultOptions: {
|
|
98
|
+
queries: { retry: false },
|
|
99
|
+
mutations: { retry: false },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const hook = (): SelfUpdateApi => {
|
|
104
|
+
if (!api) throw new Error("hook not mounted");
|
|
105
|
+
return api;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const dom = (): DomHarness => {
|
|
109
|
+
if (!harness) throw new Error("nothing mounted");
|
|
110
|
+
return harness;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Mount the surface, with `poll` deciding what each `GET /system/update` answers. */
|
|
114
|
+
async function mount(poll: () => unknown): Promise<void> {
|
|
115
|
+
http = mockFetch((call) => {
|
|
116
|
+
if (call.path.endsWith("/system/update") && call.method === "GET") {
|
|
117
|
+
return poll();
|
|
118
|
+
}
|
|
119
|
+
return accepted;
|
|
120
|
+
});
|
|
121
|
+
const Probe = () => {
|
|
122
|
+
api = useSelfUpdate();
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
harness = createDomHarness({ queryClient: appQueryClient() });
|
|
126
|
+
harness.renderApp(
|
|
127
|
+
createElement(
|
|
128
|
+
SelfUpdateProvider,
|
|
129
|
+
null,
|
|
130
|
+
createElement(Probe),
|
|
131
|
+
createElement(SelfUpdateOverlay),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
await settle();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function settle(): Promise<void> {
|
|
138
|
+
for (let round = 0; round < 40; round += 1) {
|
|
139
|
+
await dom().flush();
|
|
140
|
+
await dom().wait(0);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Press install and wait for the run this page now holds to reach the surface. */
|
|
145
|
+
async function install(): Promise<void> {
|
|
146
|
+
await act(async () => {
|
|
147
|
+
hook().install("0.9.4");
|
|
148
|
+
await dom().flush();
|
|
149
|
+
});
|
|
150
|
+
await dom().waitFor(() => {
|
|
151
|
+
const surface = hook().surface;
|
|
152
|
+
return surface.status === "ready" && surface.overlay.kind === "applying";
|
|
153
|
+
}, "the install to be accepted");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function repoll(): Promise<void> {
|
|
157
|
+
await act(async () => {
|
|
158
|
+
hook().onRetryConnection();
|
|
159
|
+
await dom().flush();
|
|
160
|
+
});
|
|
161
|
+
await settle();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
describe("the update poll across the restart it asked for (#468)", () => {
|
|
165
|
+
test("a 502 while a run is held keeps the update surface, not the fatal page", async () => {
|
|
166
|
+
let restarting = false;
|
|
167
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
168
|
+
await install();
|
|
169
|
+
|
|
170
|
+
restarting = true;
|
|
171
|
+
await repoll();
|
|
172
|
+
|
|
173
|
+
assert.equal(getCurrentFatalError(), null);
|
|
174
|
+
const surface = hook().surface;
|
|
175
|
+
assert.equal(
|
|
176
|
+
surface.status === "ready" &&
|
|
177
|
+
surface.section.status === "applying" &&
|
|
178
|
+
surface.section.phase,
|
|
179
|
+
"reconnecting",
|
|
180
|
+
);
|
|
181
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("the same 502 with no run held escalates", async () => {
|
|
185
|
+
await mount(() => httpError(502));
|
|
186
|
+
|
|
187
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a 502 with no run known from either source escalates", async () => {
|
|
191
|
+
// The server has answered, and what it said was that nothing is running.
|
|
192
|
+
let restarting = false;
|
|
193
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
194
|
+
|
|
195
|
+
restarting = true;
|
|
196
|
+
await repoll();
|
|
197
|
+
|
|
198
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("a connection refused inside the window stays soft, as any transport failure does", async () => {
|
|
202
|
+
let restarting = false;
|
|
203
|
+
await mount(() => {
|
|
204
|
+
if (restarting) throw new Error("connection refused");
|
|
205
|
+
return available;
|
|
206
|
+
});
|
|
207
|
+
await install();
|
|
208
|
+
|
|
209
|
+
restarting = true;
|
|
210
|
+
await repoll();
|
|
211
|
+
|
|
212
|
+
assert.equal(getCurrentFatalError(), null);
|
|
213
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a 500 while a run is held escalates — the server answered", async () => {
|
|
217
|
+
let broken = false;
|
|
218
|
+
await mount(() => (broken ? httpError(500) : available));
|
|
219
|
+
await install();
|
|
220
|
+
|
|
221
|
+
broken = true;
|
|
222
|
+
await repoll();
|
|
223
|
+
|
|
224
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a 502 past the apply budget gives up loudly, still not fatally", async () => {
|
|
228
|
+
let restarting = false;
|
|
229
|
+
await mount(() => (restarting ? httpError(502) : available));
|
|
230
|
+
await install();
|
|
231
|
+
|
|
232
|
+
restarting = true;
|
|
233
|
+
const realNow = Date.now;
|
|
234
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
235
|
+
try {
|
|
236
|
+
await repoll();
|
|
237
|
+
|
|
238
|
+
assert.equal(getCurrentFatalError(), null);
|
|
239
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
240
|
+
assert.match(dom().html(), /remit logs/);
|
|
241
|
+
assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
|
|
242
|
+
} finally {
|
|
243
|
+
Date.now = realNow;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The overlay speaks for a run in any tab the server tells about it, so the
|
|
250
|
+
* restart has to be ridden there too — a second tab, or the initiating one after
|
|
251
|
+
* a reload, holds nothing of its own.
|
|
252
|
+
*/
|
|
253
|
+
describe("a tab that never pressed install (#468)", () => {
|
|
254
|
+
test("holds the applying overlay across a 502", async () => {
|
|
255
|
+
let restarting = false;
|
|
256
|
+
await mount(() => (restarting ? httpError(502) : reportedRunning()));
|
|
257
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
258
|
+
|
|
259
|
+
restarting = true;
|
|
260
|
+
await repoll();
|
|
261
|
+
|
|
262
|
+
assert.equal(getCurrentFatalError(), null);
|
|
263
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("gives up loudly past the same apply budget, and names the run's own log command", async () => {
|
|
267
|
+
let restarting = false;
|
|
268
|
+
await mount(() => (restarting ? httpError(502) : reportedRunning()));
|
|
269
|
+
|
|
270
|
+
restarting = true;
|
|
271
|
+
const realNow = Date.now;
|
|
272
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
273
|
+
try {
|
|
274
|
+
await repoll();
|
|
275
|
+
|
|
276
|
+
assert.equal(getCurrentFatalError(), null);
|
|
277
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
278
|
+
assert.match(dom().html(), /remit logs --since 10m/);
|
|
279
|
+
assert.doesNotMatch(dom().html(), /Installing Remit 0\.9\.4/);
|
|
280
|
+
} finally {
|
|
281
|
+
Date.now = realNow;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a box clock behind the browser's does not spend the budget before the first 502", async () => {
|
|
286
|
+
// `startedAt` is written on the box. Twenty minutes of skew is more than
|
|
287
|
+
// the whole budget, so measuring the wait from it would open the screen on
|
|
288
|
+
// the give-up and never show the install at all.
|
|
289
|
+
const skewed = () => ({
|
|
290
|
+
...accepted.run,
|
|
291
|
+
startedAt: new Date(Date.now() - BUDGET_MS - 60_000).toISOString(),
|
|
292
|
+
});
|
|
293
|
+
let restarting = false;
|
|
294
|
+
await mount(() =>
|
|
295
|
+
restarting ? httpError(502) : { ...accepted, run: skewed() },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
restarting = true;
|
|
299
|
+
await repoll();
|
|
300
|
+
|
|
301
|
+
assert.equal(getCurrentFatalError(), null);
|
|
302
|
+
assert.match(dom().html(), /Installing Remit 0\.9\.4/);
|
|
303
|
+
assert.doesNotMatch(dom().html(), /has not answered since the restart/);
|
|
304
|
+
|
|
305
|
+
// The wait is this tab's, so it still runs out — on this tab's clock.
|
|
306
|
+
const realNow = Date.now;
|
|
307
|
+
Date.now = () => realNow() + BUDGET_MS + 60_000;
|
|
308
|
+
try {
|
|
309
|
+
await repoll();
|
|
310
|
+
|
|
311
|
+
assert.equal(getCurrentFatalError(), null);
|
|
312
|
+
assert.match(dom().html(), /has not answered since the restart/);
|
|
313
|
+
} finally {
|
|
314
|
+
Date.now = realNow;
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("says the service is unreachable once the run it knew about is finished", async () => {
|
|
319
|
+
let restarting = false;
|
|
320
|
+
await mount(() =>
|
|
321
|
+
restarting
|
|
322
|
+
? httpError(502)
|
|
323
|
+
: {
|
|
324
|
+
currentVersion: "0.9.4",
|
|
325
|
+
check: { status: "ok", updateAvailable: false },
|
|
326
|
+
run: { ...accepted.run, outcome: "succeeded" },
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
restarting = true;
|
|
331
|
+
await repoll();
|
|
332
|
+
|
|
333
|
+
const surface = hook().surface;
|
|
334
|
+
assert.equal(
|
|
335
|
+
surface.status === "ready" && surface.section.status,
|
|
336
|
+
"checkFailed",
|
|
337
|
+
);
|
|
338
|
+
assert.equal(getCurrentFatalError()?.error !== undefined, true);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
useRef,
|
|
38
38
|
useState,
|
|
39
39
|
} from "react";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
restartExpectedMeta,
|
|
42
|
+
shouldEscalate,
|
|
43
|
+
softErrorMeta,
|
|
44
|
+
} from "@/lib/error-classifier";
|
|
41
45
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
42
46
|
import {
|
|
43
47
|
appliesSchemaMigration,
|
|
@@ -48,13 +52,30 @@ import {
|
|
|
48
52
|
type HeldRun,
|
|
49
53
|
isSurfaceAbsent,
|
|
50
54
|
mapUpdatePhase,
|
|
55
|
+
type RunSighting,
|
|
51
56
|
releaseFromCheck,
|
|
57
|
+
runInFlight,
|
|
52
58
|
type UpdateSurface,
|
|
53
59
|
} from "@/lib/self-update-state";
|
|
54
60
|
|
|
55
61
|
const IDLE_POLL_MS = 30_000;
|
|
56
62
|
const RUN_POLL_MS = 5_000;
|
|
57
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The poll's own error UX, and — while a run is known to be in flight — the
|
|
66
|
+
* restart that run performs. Stopping and starting the backend is what the run
|
|
67
|
+
* is, so the gateway statuses answered while it is down belong to the surface
|
|
68
|
+
* below, which shows the phase and gives up loudly if the server never comes
|
|
69
|
+
* back (#468). Outside that window the poll carries no such claim.
|
|
70
|
+
*
|
|
71
|
+
* "Known" is either source: the run this page started and holds, or the run the
|
|
72
|
+
* server last reported as going. The overlay speaks for a run in any tab, so the
|
|
73
|
+
* window it rides has to open in any tab too — a second tab, or this one after a
|
|
74
|
+
* reload, holds nothing and would otherwise meet the restart with no claim on it.
|
|
75
|
+
*/
|
|
76
|
+
const POLL_META = softErrorMeta;
|
|
77
|
+
const POLL_ACROSS_RESTART_META = { ...softErrorMeta, ...restartExpectedMeta };
|
|
78
|
+
|
|
58
79
|
/**
|
|
59
80
|
* How long a pressed check waits for the updater before the pane calls it a
|
|
60
81
|
* failure. The backend only records the request; the updater picks it up on a
|
|
@@ -105,16 +126,25 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
105
126
|
const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
|
|
106
127
|
const [checkPress, setCheckPress] = useState<CheckPress | null>(null);
|
|
107
128
|
const [checkFailure, setCheckFailure] = useState<string | null>(null);
|
|
129
|
+
const [sighting, setSighting] = useState<RunSighting | null>(null);
|
|
108
130
|
|
|
109
131
|
const heldRef = useRef(held);
|
|
110
132
|
heldRef.current = held;
|
|
111
133
|
const pressRef = useRef(checkPress);
|
|
112
134
|
pressRef.current = checkPress;
|
|
113
135
|
|
|
136
|
+
// The last answer, which outlives the failed requests after it: a tab that
|
|
137
|
+
// never pressed install learns from here that it is inside a restart.
|
|
138
|
+
const lastKnown = queryClient.getQueryData<RemitImapSystemUpdateResponse>(
|
|
139
|
+
systemOperationsGetSystemUpdateQueryKey(),
|
|
140
|
+
);
|
|
141
|
+
const insideRestart =
|
|
142
|
+
held !== null || runInFlight(lastKnown, dismissedRunId) !== null;
|
|
143
|
+
|
|
114
144
|
const query = useQuery({
|
|
115
145
|
...systemOperationsGetSystemUpdateOptions(),
|
|
116
146
|
retry: false,
|
|
117
|
-
meta:
|
|
147
|
+
meta: insideRestart ? POLL_ACROSS_RESTART_META : POLL_META,
|
|
118
148
|
refetchInterval: (query) =>
|
|
119
149
|
pollInterval(
|
|
120
150
|
query.state.error,
|
|
@@ -135,9 +165,26 @@ export function useSystemUpdate(): SelfUpdateApi {
|
|
|
135
165
|
dismissedRunId,
|
|
136
166
|
checkPress,
|
|
137
167
|
checkFailure,
|
|
168
|
+
sighting,
|
|
138
169
|
now: Date.now(),
|
|
139
170
|
});
|
|
140
171
|
|
|
172
|
+
// Stamp the run the server reports, once, off this tab's clock. The stamp is
|
|
173
|
+
// what the give-up is measured against, so it must not move while the server
|
|
174
|
+
// is unreachable — and it must not carry over to the next run.
|
|
175
|
+
const reported = runInFlight(query.data, dismissedRunId);
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (reported === null) {
|
|
178
|
+
setSighting((current) => (current === null ? current : null));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
setSighting((current) =>
|
|
182
|
+
current !== null && current.runId === reported.runId
|
|
183
|
+
? current
|
|
184
|
+
: { runId: reported.runId, observedAt: Date.now() },
|
|
185
|
+
);
|
|
186
|
+
}, [reported]);
|
|
187
|
+
|
|
141
188
|
const shownRunIdRef = useRef<string | null>(null);
|
|
142
189
|
shownRunIdRef.current =
|
|
143
190
|
derived.surface.status === "ready" && "runId" in derived.surface.section
|
|
@@ -84,7 +84,9 @@ export interface UseEscalatedActionsResult {
|
|
|
84
84
|
* selection to that predicate once it answers. */
|
|
85
85
|
escalate: () => void;
|
|
86
86
|
/**
|
|
87
|
-
* Stop whatever's running — the count or an action
|
|
87
|
+
* Stop whatever's running — the count or an action. The request in flight is
|
|
88
|
+
* aborted and nothing further leaves (#113); a delete the server has already
|
|
89
|
+
* accepted still applies, so the batch on the wire is what a stop is worth.
|
|
88
90
|
* A no-op when nothing is running. The only thing that ends a run in
|
|
89
91
|
* flight: leaving the selection, the wizard or the search does not.
|
|
90
92
|
*/
|
|
@@ -147,7 +149,10 @@ export const useEscalatedActions = ({
|
|
|
147
149
|
const [progress, setProgress] = useState<BulkActionProgress | undefined>(
|
|
148
150
|
undefined,
|
|
149
151
|
);
|
|
150
|
-
|
|
152
|
+
// Cancellation is the signal every call the run makes is issued under, so a
|
|
153
|
+
// stop ends the request on the wire instead of waiting for it to come back
|
|
154
|
+
// (#113). Replaced, never reused, at the start of each count and each run.
|
|
155
|
+
const abortRef = useRef<AbortController | undefined>(undefined);
|
|
151
156
|
// True from the moment a run starts until its outcome is in hand. A run is
|
|
152
157
|
// mail already leaving the mailbox, so nothing that merely changes what the
|
|
153
158
|
// list is showing gets to end it — only `stop`.
|
|
@@ -170,7 +175,7 @@ export const useEscalatedActions = ({
|
|
|
170
175
|
// against and reports what it reached, wherever the list moved on to.
|
|
171
176
|
// biome-ignore lint/correctness/useExhaustiveDependencies: enabled/predicateKey are trigger-only — the reset itself reads neither.
|
|
172
177
|
useEffect(() => {
|
|
173
|
-
if (!runningRef.current)
|
|
178
|
+
if (!runningRef.current) abortRef.current?.abort();
|
|
174
179
|
setPhase({ kind: "idle" });
|
|
175
180
|
}, [enabled, predicateKey]);
|
|
176
181
|
|
|
@@ -179,10 +184,11 @@ export const useEscalatedActions = ({
|
|
|
179
184
|
|
|
180
185
|
const fetchPagesOf = useCallback(
|
|
181
186
|
(query: EscalationSearchQuery): FetchIdsPage =>
|
|
182
|
-
async (continuationToken) => {
|
|
187
|
+
async (continuationToken, signal) => {
|
|
183
188
|
const { data } = await threadOperationsSearchThreads({
|
|
184
189
|
path: { mailboxId },
|
|
185
190
|
query: { ...query, continuationToken, limit: PAGE_SIZE },
|
|
191
|
+
signal,
|
|
186
192
|
throwOnError: true,
|
|
187
193
|
});
|
|
188
194
|
return {
|
|
@@ -198,27 +204,32 @@ export const useEscalatedActions = ({
|
|
|
198
204
|
* resolves it (#509). One count-only request: `limit` is a page size and has
|
|
199
205
|
* no bearing on the answer, so nothing is paged to arrive at it.
|
|
200
206
|
*/
|
|
201
|
-
const fetchMatchCount = useCallback(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
const fetchMatchCount = useCallback(
|
|
208
|
+
async (signal: AbortSignal): Promise<number> => {
|
|
209
|
+
const { data } = await threadOperationsSearchThreads({
|
|
210
|
+
path: { mailboxId },
|
|
211
|
+
query: { ...searchQueryRef.current, count: true, results: false },
|
|
212
|
+
signal,
|
|
213
|
+
throwOnError: true,
|
|
214
|
+
});
|
|
215
|
+
if (data.count === undefined) {
|
|
216
|
+
throw new Error("the search returned no count for the selection");
|
|
217
|
+
}
|
|
218
|
+
return data.count;
|
|
219
|
+
},
|
|
220
|
+
[mailboxId],
|
|
221
|
+
);
|
|
212
222
|
|
|
213
223
|
const applyBatchFor = useCallback(
|
|
214
224
|
(action: EscalatedAction): ApplyBatch =>
|
|
215
|
-
async (ids: string[]) => {
|
|
225
|
+
async (ids: string[], signal: AbortSignal) => {
|
|
216
226
|
if (action.kind === "move") {
|
|
217
227
|
const { data } = await messageBulkOperationsMoveMessages({
|
|
218
228
|
body: {
|
|
219
229
|
messageIds: ids,
|
|
220
230
|
destinationMailboxId: action.destinationMailboxId,
|
|
221
231
|
},
|
|
232
|
+
signal,
|
|
222
233
|
throwOnError: true,
|
|
223
234
|
});
|
|
224
235
|
return data;
|
|
@@ -226,12 +237,14 @@ export const useEscalatedActions = ({
|
|
|
226
237
|
if (action.kind === "markRead") {
|
|
227
238
|
const { data } = await messageBulkOperationsUpdateFlags({
|
|
228
239
|
body: { messageIds: ids, isRead: true },
|
|
240
|
+
signal,
|
|
229
241
|
throwOnError: true,
|
|
230
242
|
});
|
|
231
243
|
return data;
|
|
232
244
|
}
|
|
233
245
|
const { data } = await messageBulkOperationsDeleteMessages({
|
|
234
246
|
body: { messageIds: ids },
|
|
247
|
+
signal,
|
|
235
248
|
throwOnError: true,
|
|
236
249
|
});
|
|
237
250
|
return data;
|
|
@@ -267,17 +280,24 @@ export const useEscalatedActions = ({
|
|
|
267
280
|
);
|
|
268
281
|
|
|
269
282
|
const escalate = useCallback(() => {
|
|
270
|
-
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
abortRef.current = controller;
|
|
271
285
|
setPhase({ kind: "counting" });
|
|
272
|
-
fetchMatchCount().then(
|
|
286
|
+
fetchMatchCount(controller.signal).then(
|
|
273
287
|
(total) => {
|
|
274
|
-
if (
|
|
288
|
+
if (controller.signal.aborted) {
|
|
275
289
|
setPhase({ kind: "idle" });
|
|
276
290
|
return;
|
|
277
291
|
}
|
|
278
292
|
setPhase({ kind: "escalated", total });
|
|
279
293
|
},
|
|
280
294
|
(error: unknown) => {
|
|
295
|
+
// A stopped count rejects with its own abort. That is the press the
|
|
296
|
+
// user made, not a failure to report back to them.
|
|
297
|
+
if (controller.signal.aborted) {
|
|
298
|
+
setPhase({ kind: "idle" });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
281
301
|
pushError(
|
|
282
302
|
buildMutationErrorBanner(
|
|
283
303
|
"Couldn't count matching messages",
|
|
@@ -291,12 +311,12 @@ export const useEscalatedActions = ({
|
|
|
291
311
|
}, [fetchMatchCount, pushError]);
|
|
292
312
|
|
|
293
313
|
const stop = useCallback(() => {
|
|
294
|
-
|
|
314
|
+
abortRef.current?.abort();
|
|
295
315
|
}, []);
|
|
296
316
|
|
|
297
317
|
const clear = useCallback(() => {
|
|
298
318
|
if (runningRef.current) return;
|
|
299
|
-
|
|
319
|
+
abortRef.current?.abort();
|
|
300
320
|
setPhase({ kind: "idle" });
|
|
301
321
|
}, []);
|
|
302
322
|
|
|
@@ -305,7 +325,8 @@ export const useEscalatedActions = ({
|
|
|
305
325
|
action: EscalatedAction,
|
|
306
326
|
targets?: readonly BulkActionTarget[],
|
|
307
327
|
): Promise<BulkRunOutcome> => {
|
|
308
|
-
|
|
328
|
+
const controller = new AbortController();
|
|
329
|
+
abortRef.current = controller;
|
|
309
330
|
runningRef.current = true;
|
|
310
331
|
setRunningAction(action);
|
|
311
332
|
// Read before the run clears the phase below, so a refusal can say how
|
|
@@ -334,14 +355,14 @@ export const useEscalatedActions = ({
|
|
|
334
355
|
targets,
|
|
335
356
|
applyBatch,
|
|
336
357
|
onProgress,
|
|
337
|
-
|
|
358
|
+
controller.signal,
|
|
338
359
|
)
|
|
339
360
|
: await runPredicateAction(
|
|
340
361
|
runPages,
|
|
341
362
|
phase.kind === "escalated" ? phase.total : 0,
|
|
342
363
|
applyBatch,
|
|
343
364
|
onProgress,
|
|
344
|
-
|
|
365
|
+
controller.signal,
|
|
345
366
|
);
|
|
346
367
|
} finally {
|
|
347
368
|
runningRef.current = false;
|
|
@@ -68,19 +68,29 @@ describe("bulkActionFailureTitle", () => {
|
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
describe("a run that stopped short of its match", () => {
|
|
71
|
-
test("states what it reached and
|
|
71
|
+
test("states what it reached and what became of the rest", () => {
|
|
72
72
|
assert.equal(bulkActionStoppedTitle(1200), "Stopped after 1,200");
|
|
73
73
|
assert.equal(
|
|
74
74
|
bulkActionStoppedDetail("delete", 1200, 3412),
|
|
75
|
-
"1,200 of 3,412 moved to Trash.
|
|
75
|
+
"1,200 of 3,412 moved to Trash. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.",
|
|
76
76
|
);
|
|
77
77
|
assert.equal(
|
|
78
78
|
bulkActionStoppedDetail("markRead", 1200, 3412),
|
|
79
|
-
"1,200 of 3,412 marked as read.
|
|
79
|
+
"1,200 of 3,412 marked as read. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.",
|
|
80
80
|
);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// Stopping aborts the request on the wire; it does not un-send one the server
|
|
84
|
+
// already took (#113). Promising the remainder is untouched would be a claim
|
|
85
|
+
// the client cannot make about the batch that was in flight.
|
|
86
|
+
test("stops short of promising the batch in flight never landed", () => {
|
|
87
|
+
assert.match(
|
|
88
|
+
bulkActionStoppedDetail("move", 1, 2),
|
|
89
|
+
/may still have gone through/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("never reads as a rejection — nothing was refused", () => {
|
|
84
94
|
assert.doesNotMatch(
|
|
85
95
|
bulkActionStoppedDetail("move", 1, 2),
|
|
86
96
|
/rejected|couldn't/i,
|
|
@@ -86,9 +86,11 @@ export const bulkActionCompletionText = (
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* Shown when a run ended before it covered what it was started against. The
|
|
89
|
-
* remainder was never sent, so
|
|
89
|
+
* remainder was never sent, so that mail is where it was and only the user can
|
|
90
90
|
* decide to run it again — which is why this is stated rather than left to a
|
|
91
|
-
* list that quietly stops changing.
|
|
91
|
+
* list that quietly stops changing. Stopping aborts the request already on the
|
|
92
|
+
* wire rather than un-sending it (#113), so what the copy can promise about the
|
|
93
|
+
* remainder stops short of the batch that was in flight.
|
|
92
94
|
*/
|
|
93
95
|
export const bulkActionStoppedTitle = (done: number): string =>
|
|
94
96
|
`Stopped after ${formatNumber(done)}`;
|
|
@@ -99,7 +101,7 @@ export const bulkActionStoppedDetail = (
|
|
|
99
101
|
total: number,
|
|
100
102
|
outcome: DeleteOutcome = "trash",
|
|
101
103
|
): string =>
|
|
102
|
-
`${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}.
|
|
104
|
+
`${formatNumber(done)} of ${formatNumber(total)} ${pastTenseFor(kind, outcome)}. The rest were not sent, apart from the ones already on their way when you stopped — those may still have gone through.`;
|
|
103
105
|
|
|
104
106
|
/** Error-banner title for a run stopped by an infrastructure failure. */
|
|
105
107
|
export const bulkActionFailureTitle = (
|