@remit/web-client 0.0.268 → 0.0.270

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.
@@ -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 — at the next boundary.
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
- const cancelRef = useRef(false);
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) cancelRef.current = true;
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(async (): Promise<number> => {
202
- const { data } = await threadOperationsSearchThreads({
203
- path: { mailboxId },
204
- query: { ...searchQueryRef.current, count: true, results: false },
205
- throwOnError: true,
206
- });
207
- if (data.count === undefined) {
208
- throw new Error("the search returned no count for the selection");
209
- }
210
- return data.count;
211
- }, [mailboxId]);
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
- cancelRef.current = false;
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 (cancelRef.current) {
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
- cancelRef.current = true;
314
+ abortRef.current?.abort();
295
315
  }, []);
296
316
 
297
317
  const clear = useCallback(() => {
298
318
  if (runningRef.current) return;
299
- cancelRef.current = true;
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
- cancelRef.current = false;
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
- () => cancelRef.current,
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
- () => cancelRef.current,
365
+ controller.signal,
345
366
  );
346
367
  } finally {
347
368
  runningRef.current = false;
@@ -8,7 +8,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
8
8
  import { useCallback } from "react";
9
9
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
10
10
  import { formatErrorDetail } from "@/components/ui/error-banners";
11
+ import {
12
+ isPlacementRefusal,
13
+ placementRefusalBanner,
14
+ } from "@/components/ui/placement-refusal";
11
15
  import { runChunkedMutation } from "@/lib/bulk-actions";
16
+ import { softErrorStatuses } from "@/lib/error-classifier";
12
17
  import {
13
18
  cancelThreadListQueries,
14
19
  invalidateThreadListQueries,
@@ -57,6 +62,10 @@ export const useMoveMessages = ({
57
62
 
58
63
  const { mutateAsync, isPending, isError } = useMutation({
59
64
  ...messageBulkOperationsMoveMessagesMutation(),
65
+ // A coded 409 is answered here — a banner — so it must not also take the
66
+ // whole screen. Only 409: a 401, 403 or 5xx on a move is still the fatal
67
+ // page's (#1059).
68
+ meta: softErrorStatuses(409),
60
69
  onMutate: async (variables): Promise<ThreadMutationContext> => {
61
70
  const messageIds = new Set(variables.body.messageIds ?? []);
62
71
 
@@ -136,6 +145,13 @@ export const useMoveMessages = ({
136
145
  restoreThreadListQueries(queryClient, context.previousThreadsList);
137
146
  }
138
147
  const count = vars.body.messageIds?.length ?? 0;
148
+ // A placement refusal is a wait or a resync, not a failure to report as
149
+ // one: the move was never made, and the banner says which.
150
+ const placement = isPlacementRefusal(err);
151
+ if (placement) {
152
+ pushError(placementRefusalBanner(placement, count, "move"));
153
+ return;
154
+ }
139
155
  pushError({
140
156
  title:
141
157
  count > 1
@@ -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 that the rest is untouched", () => {
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. Nothing was sent for the rest, so they are untouched.",
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. Nothing was sent for the rest, so they are untouched.",
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
- test("never reads as a rejection — nothing was sent", () => {
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 the mail is where it was and only the user can
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)}. Nothing was sent for the rest, so they are untouched.`;
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 = (
@@ -11,6 +11,24 @@ import {
11
11
  runPredicateAction,
12
12
  } from "./bulk-actions.js";
13
13
 
14
+ /** A run nobody stops. */
15
+ const neverAborted = (): AbortSignal => new AbortController().signal;
16
+
17
+ /**
18
+ * What an aborted request does: reject with the signal's own reason, whether it
19
+ * was already aborted when the call was made or aborted while in flight.
20
+ */
21
+ const abortable = (signal: AbortSignal): Promise<never> =>
22
+ new Promise((_, reject) => {
23
+ if (signal.aborted) {
24
+ reject(signal.reason);
25
+ return;
26
+ }
27
+ signal.addEventListener("abort", () => reject(signal.reason), {
28
+ once: true,
29
+ });
30
+ });
31
+
14
32
  const ids = (count: number, prefix = "m"): string[] =>
15
33
  Array.from({ length: count }, (_, i) => `${prefix}${i}`);
16
34
 
@@ -112,7 +130,6 @@ describe("chunkTargets", () => {
112
130
  });
113
131
 
114
132
  describe("runChunkedAction", () => {
115
- const neverCancelled = () => false;
116
133
  const noopProgress = () => undefined;
117
134
 
118
135
  test("zero ids does nothing and reports done=0", async () => {
@@ -124,7 +141,7 @@ describe("runChunkedAction", () => {
124
141
  return { successCount: chunk.length, failureCount: 0 };
125
142
  },
126
143
  noopProgress,
127
- neverCancelled,
144
+ neverAborted(),
128
145
  );
129
146
  assert.deepEqual(outcome, {
130
147
  done: 0,
@@ -144,7 +161,7 @@ describe("runChunkedAction", () => {
144
161
  return { successCount: chunk.length, failureCount: 0 };
145
162
  },
146
163
  noopProgress,
147
- neverCancelled,
164
+ neverAborted(),
148
165
  );
149
166
  assert.equal(calls.length, 2);
150
167
  assert.equal(calls[0].length, BULK_ACTION_CHUNK_SIZE);
@@ -159,7 +176,7 @@ describe("runChunkedAction", () => {
159
176
  input,
160
177
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
161
178
  noopProgress,
162
- neverCancelled,
179
+ neverAborted(),
163
180
  );
164
181
  assert.equal(outcome.done, input.length);
165
182
  assert.deepEqual(outcome.failedIds, []);
@@ -168,16 +185,16 @@ describe("runChunkedAction", () => {
168
185
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
169
186
  const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
170
187
  let calls = 0;
171
- let cancelled = false;
188
+ const stop = new AbortController();
172
189
  const outcome = await runChunkedAction(
173
190
  input,
174
191
  async (chunk) => {
175
192
  calls++;
176
- if (calls === 1) cancelled = true; // cancel after the first chunk lands
193
+ if (calls === 1) stop.abort(); // cancel after the first chunk lands
177
194
  return { successCount: chunk.length, failureCount: 0 };
178
195
  },
179
196
  () => undefined,
180
- () => cancelled,
197
+ stop.signal,
181
198
  );
182
199
  assert.equal(outcome.cancelled, true);
183
200
  assert.equal(calls, 1);
@@ -195,7 +212,7 @@ describe("runChunkedAction", () => {
195
212
  throw boom;
196
213
  },
197
214
  noopProgress,
198
- neverCancelled,
215
+ neverAborted(),
199
216
  );
200
217
  assert.equal(outcome.error, boom);
201
218
  assert.equal(outcome.done, 0);
@@ -215,7 +232,7 @@ describe("runChunkedAction", () => {
215
232
  return { successCount: chunk.length, failureCount: 0 };
216
233
  },
217
234
  noopProgress,
218
- neverCancelled,
235
+ neverAborted(),
219
236
  );
220
237
  assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
221
238
  assert.equal(outcome.done, 3);
@@ -231,7 +248,7 @@ describe("runChunkedAction", () => {
231
248
  ],
232
249
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
233
250
  (p) => seen.push(p),
234
- neverCancelled,
251
+ neverAborted(),
235
252
  );
236
253
  assert.deepEqual(seen, [
237
254
  { done: 1, total: 2 },
@@ -240,7 +257,7 @@ describe("runChunkedAction", () => {
240
257
  });
241
258
 
242
259
  test("cancelling at an account boundary hands back the accounts never reached", async () => {
243
- let cancelled = false;
260
+ const stop = new AbortController();
244
261
  const outcome = await runChunkedAction(
245
262
  [
246
263
  { id: "a1", accountId: "acct-a" },
@@ -248,11 +265,11 @@ describe("runChunkedAction", () => {
248
265
  { id: "c1", accountId: "acct-c" },
249
266
  ],
250
267
  async (chunk) => {
251
- cancelled = true;
268
+ stop.abort();
252
269
  return { successCount: chunk.length, failureCount: 0 };
253
270
  },
254
271
  () => undefined,
255
- () => cancelled,
272
+ stop.signal,
256
273
  );
257
274
  assert.equal(outcome.cancelled, true);
258
275
  assert.equal(outcome.done, 1);
@@ -271,7 +288,7 @@ describe("runChunkedAction", () => {
271
288
  return { successCount: chunk.length, failureCount: 0 };
272
289
  },
273
290
  () => undefined,
274
- neverCancelled,
291
+ neverAborted(),
275
292
  );
276
293
  assert.equal(outcome.error, boom);
277
294
  assert.equal(outcome.done, 1);
@@ -291,7 +308,7 @@ describe("runChunkedAction", () => {
291
308
  throw new Error("auth expired");
292
309
  },
293
310
  () => undefined,
294
- neverCancelled,
311
+ neverAborted(),
295
312
  );
296
313
  // The run stops where it threw rather than trying the accounts behind it:
297
314
  // what it hands back is exactly what is still untouched, which is what a
@@ -308,7 +325,7 @@ describe("runChunkedAction", () => {
308
325
  input,
309
326
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
310
327
  (p) => progressCalls.push(p),
311
- neverCancelled,
328
+ neverAborted(),
312
329
  );
313
330
  assert.deepEqual(progressCalls, [
314
331
  { done: BULK_ACTION_CHUNK_SIZE, total: input.length },
@@ -318,7 +335,6 @@ describe("runChunkedAction", () => {
318
335
  });
319
336
 
320
337
  describe("runPredicateAction", () => {
321
- const neverCancelled = () => false;
322
338
  const noopProgress = () => undefined;
323
339
 
324
340
  /** Builds a paged fixture: `pages[i]` is what the i-th call returns. */
@@ -338,7 +354,7 @@ describe("runPredicateAction", () => {
338
354
  0,
339
355
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
340
356
  noopProgress,
341
- neverCancelled,
357
+ neverAborted(),
342
358
  );
343
359
  assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
344
360
  });
@@ -354,7 +370,7 @@ describe("runPredicateAction", () => {
354
370
  return { successCount: chunk.length, failureCount: 0 };
355
371
  },
356
372
  noopProgress,
357
- neverCancelled,
373
+ neverAborted(),
358
374
  );
359
375
  assert.equal(deleteCalls.length, 1);
360
376
  assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
@@ -374,7 +390,7 @@ describe("runPredicateAction", () => {
374
390
  return { successCount: chunk.length, failureCount: 0 };
375
391
  },
376
392
  noopProgress,
377
- neverCancelled,
393
+ neverAborted(),
378
394
  );
379
395
  assert.equal(deleteCalls.length, 2);
380
396
  assert.equal(outcome.done, 150);
@@ -397,7 +413,7 @@ describe("runPredicateAction", () => {
397
413
  return { successCount: chunk.length, failureCount: 0 };
398
414
  },
399
415
  noopProgress,
400
- neverCancelled,
416
+ neverAborted(),
401
417
  );
402
418
  assert.deepEqual(deleteCalls, [
403
419
  ["a", "b"],
@@ -408,7 +424,7 @@ describe("runPredicateAction", () => {
408
424
 
409
425
  test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
410
426
  let fetchCalls = 0;
411
- let cancelled = false;
427
+ const stop = new AbortController();
412
428
  const fetch = async (): Promise<FetchIdsPageResult> => {
413
429
  fetchCalls++;
414
430
  return { ids: ["a", "b"], continuationToken: "more" };
@@ -417,11 +433,11 @@ describe("runPredicateAction", () => {
417
433
  fetch,
418
434
  1000,
419
435
  async (chunk) => {
420
- cancelled = true; // cancel takes effect on the next loop iteration
436
+ stop.abort(); // cancel takes effect on the next loop iteration
421
437
  return { successCount: chunk.length, failureCount: 0 };
422
438
  },
423
439
  () => undefined,
424
- () => cancelled,
440
+ stop.signal,
425
441
  );
426
442
  assert.equal(outcome.cancelled, true);
427
443
  assert.equal(fetchCalls, 1);
@@ -439,7 +455,7 @@ describe("runPredicateAction", () => {
439
455
  100,
440
456
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
441
457
  noopProgress,
442
- neverCancelled,
458
+ neverAborted(),
443
459
  );
444
460
  assert.equal(outcome.error, boom);
445
461
  assert.equal(outcome.done, 0);
@@ -455,12 +471,85 @@ describe("runPredicateAction", () => {
455
471
  throw boom;
456
472
  },
457
473
  noopProgress,
458
- neverCancelled,
474
+ neverAborted(),
459
475
  );
460
476
  assert.equal(outcome.error, boom);
461
477
  });
462
478
  });
463
479
 
480
+ // Regression for #113: a stop used to be a flag read between pages, so the
481
+ // search or delete already on the wire ran to completion — up to a hundred
482
+ // messages deleted after the press. The run's signal is now the call's signal.
483
+ describe("stopping a run that has a request in flight", () => {
484
+ test("a bounded run reports the aborted batch as a clean stop, never an error", async () => {
485
+ const stop = new AbortController();
486
+ const calls: string[][] = [];
487
+ const outcome = await runChunkedAction(
488
+ targets(BULK_ACTION_CHUNK_SIZE * 3),
489
+ async (chunk, signal) => {
490
+ calls.push(chunk);
491
+ stop.abort();
492
+ return abortable(signal);
493
+ },
494
+ () => undefined,
495
+ stop.signal,
496
+ );
497
+ assert.equal(outcome.cancelled, true);
498
+ assert.equal(outcome.error, undefined);
499
+ assert.equal(calls.length, 1, "nothing further left after the stop");
500
+ assert.equal(outcome.done, 0);
501
+ // The aborted batch may or may not have been accepted server-side, so it
502
+ // comes back with the chunks never attempted rather than counted as done.
503
+ assert.equal(outcome.failedIds.length, BULK_ACTION_CHUNK_SIZE * 3);
504
+ });
505
+
506
+ test("a predicate run whose delete is aborted sends nothing more and reports a clean stop", async () => {
507
+ const stop = new AbortController();
508
+ let fetches = 0;
509
+ let deletes = 0;
510
+ const outcome = await runPredicateAction(
511
+ async () => {
512
+ fetches++;
513
+ return { ids: ["a", "b"], continuationToken: "more" };
514
+ },
515
+ 1000,
516
+ async (_chunk, signal) => {
517
+ deletes++;
518
+ stop.abort();
519
+ return abortable(signal);
520
+ },
521
+ () => undefined,
522
+ stop.signal,
523
+ );
524
+ assert.equal(outcome.cancelled, true);
525
+ assert.equal(outcome.error, undefined);
526
+ assert.equal(deletes, 1);
527
+ assert.equal(fetches, 1);
528
+ assert.equal(outcome.done, 0);
529
+ });
530
+
531
+ test("a predicate run whose search page is aborted deletes nothing", async () => {
532
+ const stop = new AbortController();
533
+ let deletes = 0;
534
+ const outcome = await runPredicateAction(
535
+ async (_token, signal) => {
536
+ stop.abort();
537
+ return abortable(signal);
538
+ },
539
+ 1000,
540
+ async (chunk) => {
541
+ deletes++;
542
+ return { successCount: chunk.length, failureCount: 0 };
543
+ },
544
+ () => undefined,
545
+ stop.signal,
546
+ );
547
+ assert.equal(outcome.cancelled, true);
548
+ assert.equal(outcome.error, undefined);
549
+ assert.equal(deletes, 0);
550
+ });
551
+ });
552
+
464
553
  describe("honestProgress", () => {
465
554
  // Regression for #109: the count and `runPredicateAction` resolve the same
466
555
  // predicate independently, so the delete can outrun the frozen `total` it