@remit/web-client 0.0.111 → 0.0.112

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.111",
3
+ "version": "0.0.112",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -45,11 +45,7 @@ import {
45
45
  escalatedStatusLabel,
46
46
  escalationActionLabel,
47
47
  } from "@/lib/escalation-label";
48
- import {
49
- formatDeleteToTrashTitle,
50
- formatEmailDate,
51
- formatNumber,
52
- } from "@/lib/format";
48
+ import { formatDeleteToTrashTitle, formatEmailDate } from "@/lib/format";
53
49
  import { tabStopId } from "@/lib/list-focus";
54
50
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
55
51
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
@@ -1099,9 +1095,7 @@ export const MessageList = ({
1099
1095
  escalation.progress?.total ?? selectionCount,
1100
1096
  )
1101
1097
  : escalation.phase.kind === "counting"
1102
- ? escalation.phase.countSoFar >= 5000
1103
- ? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
1104
- : `Counting… ${formatNumber(escalation.phase.countSoFar)} so far`
1098
+ ? "Counting matches…"
1105
1099
  : escalation.phase.kind === "escalated"
1106
1100
  ? escalatedStatusLabel(searchPredicate ?? {}, escalation.phase.total)
1107
1101
  : undefined;
@@ -42,10 +42,12 @@ interface MailServer {
42
42
  /**
43
43
  * A search that pages `TOTAL` ids at the hook's own page size, keyed by the
44
44
  * query, so a run started against one predicate is distinguishable from a run
45
- * that followed the list onto a later one.
45
+ * that followed the list onto a later one. A count-only request is answered
46
+ * with the whole match set in one response, as the server answers it.
46
47
  */
47
48
  const searchPage = (url: URL): unknown => {
48
49
  const query = url.searchParams.get("query") ?? "";
50
+ if (url.searchParams.get("results") === "false") return { count: TOTAL };
49
51
  const served = Number(url.searchParams.get("continuationToken") ?? "0");
50
52
  const size = Math.min(PAGE_SIZE, Math.max(TOTAL - served, 0));
51
53
  return {
@@ -18,7 +18,6 @@ import {
18
18
  type ApplyBatch,
19
19
  type BulkActionProgress,
20
20
  type BulkRunOutcome,
21
- countMatches,
22
21
  type FetchIdsPage,
23
22
  honestProgress,
24
23
  runChunkedAction,
@@ -53,16 +52,14 @@ export type EscalatedAction =
53
52
  | { kind: "move"; destinationMailboxId: string }
54
53
  | { kind: "markRead" };
55
54
 
56
- /** Page size for both the counting and the execution loop. Set to the write
57
- * side's own 100-id cap so an execution page IS a write chunk — no
58
- * in-memory accumulation step between reading ids and sending them. Counting
59
- * doesn't have that constraint but reuses the same page size rather than
60
- * adding a second one to reason about. */
55
+ /** Page size for the execution loop. Set to the write side's own 100-id cap so
56
+ * an execution page IS a write chunk — no in-memory accumulation step between
57
+ * reading ids and sending them. */
61
58
  const PAGE_SIZE = 100;
62
59
 
63
60
  export type EscalationPhase =
64
61
  | { kind: "idle" }
65
- | { kind: "counting"; countSoFar: number }
62
+ | { kind: "counting" }
66
63
  | { kind: "escalated"; total: number };
67
64
 
68
65
  interface UseEscalatedActionsOptions {
@@ -80,12 +77,13 @@ interface UseEscalatedActionsOptions {
80
77
 
81
78
  export interface UseEscalatedActionsResult {
82
79
  phase: EscalationPhase;
83
- /** Begin paging the predicate's full match set to find its total. */
80
+ /** Ask the server how many messages the predicate matches, and switch the
81
+ * selection to that predicate once it answers. */
84
82
  escalate: () => void;
85
83
  /**
86
- * Stop whatever's running — counting or an action — at the next page
87
- * boundary. A no-op when nothing is running. The only thing that ends a run
88
- * in flight: leaving the selection, the wizard or the search does not.
84
+ * Stop whatever's running — the count or an action — at the next boundary.
85
+ * A no-op when nothing is running. The only thing that ends a run in
86
+ * flight: leaving the selection, the wizard or the search does not.
89
87
  */
90
88
  stop: () => void;
91
89
  /**
@@ -181,11 +179,22 @@ export const useEscalatedActions = ({
181
179
  [mailboxId],
182
180
  );
183
181
 
184
- const fetchIdsPage = useCallback<FetchIdsPage>(
185
- (continuationToken) =>
186
- fetchPagesOf(searchQueryRef.current)(continuationToken),
187
- [fetchPagesOf],
188
- );
182
+ /**
183
+ * How many messages the predicate matches, straight from the server that
184
+ * resolves it (#509). One count-only request: `limit` is a page size and has
185
+ * no bearing on the answer, so nothing is paged to arrive at it.
186
+ */
187
+ const fetchMatchCount = useCallback(async (): Promise<number> => {
188
+ const { data } = await threadOperationsSearchThreads({
189
+ path: { mailboxId },
190
+ query: { ...searchQueryRef.current, count: true, results: false },
191
+ throwOnError: true,
192
+ });
193
+ if (data.count === undefined) {
194
+ throw new Error("the search returned no count for the selection");
195
+ }
196
+ return data.count;
197
+ }, [mailboxId]);
189
198
 
190
199
  const applyBatchFor = useCallback(
191
200
  (action: EscalatedAction): ApplyBatch =>
@@ -235,30 +244,27 @@ export const useEscalatedActions = ({
235
244
 
236
245
  const escalate = useCallback(() => {
237
246
  cancelRef.current = false;
238
- setPhase({ kind: "counting", countSoFar: 0 });
239
- countMatches(
240
- fetchIdsPage,
241
- (countSoFar) => setPhase({ kind: "counting", countSoFar }),
242
- () => cancelRef.current,
243
- ).then((result) => {
244
- if (result.error) {
247
+ setPhase({ kind: "counting" });
248
+ fetchMatchCount().then(
249
+ (total) => {
250
+ if (cancelRef.current) {
251
+ setPhase({ kind: "idle" });
252
+ return;
253
+ }
254
+ setPhase({ kind: "escalated", total });
255
+ },
256
+ (error: unknown) => {
245
257
  pushError(
246
258
  buildMutationErrorBanner(
247
259
  "Couldn't count matching messages",
248
260
  "The count didn't finish.",
249
- result.error,
261
+ error,
250
262
  ),
251
263
  );
252
264
  setPhase({ kind: "idle" });
253
- return;
254
- }
255
- if (result.cancelled) {
256
- setPhase({ kind: "idle" });
257
- return;
258
- }
259
- setPhase({ kind: "escalated", total: result.total });
260
- });
261
- }, [fetchIdsPage, pushError]);
265
+ },
266
+ );
267
+ }, [fetchMatchCount, pushError]);
262
268
 
263
269
  const stop = useCallback(() => {
264
270
  cancelRef.current = true;
@@ -279,8 +285,8 @@ export const useEscalatedActions = ({
279
285
  runningRef.current = true;
280
286
  setRunningAction(action);
281
287
  // `honestProgress` widens `total` if `done` overtakes it (#109) — the
282
- // predicate can match more by the time the run re-pages it than
283
- // `countMatches` saw, and the bar must never show more done than out of.
288
+ // predicate can match more by the time the run pages it than the count
289
+ // saw, and the bar must never show more done than out of.
284
290
  const onProgress = (next: BulkActionProgress) =>
285
291
  setProgress(honestProgress(next));
286
292
  const applyBatch = applyBatchFor(action);
@@ -3,7 +3,6 @@ import { describe, test } from "node:test";
3
3
  import {
4
4
  BULK_ACTION_CHUNK_SIZE,
5
5
  chunkIds,
6
- countMatches,
7
6
  type FetchIdsPageResult,
8
7
  honestProgress,
9
8
  runChunkedAction,
@@ -297,67 +296,10 @@ describe("runPredicateAction", () => {
297
296
  });
298
297
  });
299
298
 
300
- describe("countMatches", () => {
301
- const neverCancelled = () => false;
302
-
303
- test("counts across every page until the token is exhausted", async () => {
304
- let call = 0;
305
- const pages: FetchIdsPageResult[] = [
306
- { ids: ids(500, "a"), continuationToken: "t1" },
307
- { ids: ids(500, "b"), continuationToken: "t2" },
308
- { ids: ids(412, "c") },
309
- ];
310
- const fetch = async () => pages[call++];
311
- const outcome = await countMatches(fetch, () => undefined, neverCancelled);
312
- assert.deepEqual(outcome, { total: 1412, cancelled: false });
313
- });
314
-
315
- test("reports a running total after each page", async () => {
316
- let call = 0;
317
- const pages: FetchIdsPageResult[] = [
318
- { ids: ids(500), continuationToken: "t1" },
319
- { ids: ids(300) },
320
- ];
321
- const fetch = async () => pages[call++];
322
- const progressCalls: number[] = [];
323
- await countMatches(fetch, (n) => progressCalls.push(n), neverCancelled);
324
- assert.deepEqual(progressCalls, [500, 800]);
325
- });
326
-
327
- test("cancelling mid-count stops paging and reports what it had so far", async () => {
328
- let call = 0;
329
- let cancelled = false;
330
- const fetch = async (): Promise<FetchIdsPageResult> => {
331
- call++;
332
- return { ids: ids(500), continuationToken: "more" };
333
- };
334
- const outcome = await countMatches(
335
- fetch,
336
- () => {
337
- cancelled = true;
338
- },
339
- () => cancelled,
340
- );
341
- assert.equal(outcome.cancelled, true);
342
- assert.equal(call, 1);
343
- assert.equal(outcome.total, 500);
344
- });
345
-
346
- test("an infra failure while paging stops the count and reports the error", async () => {
347
- const boom = new Error("network blip");
348
- const fetch = async (): Promise<FetchIdsPageResult> => {
349
- throw boom;
350
- };
351
- const outcome = await countMatches(fetch, () => undefined, neverCancelled);
352
- assert.equal(outcome.error, boom);
353
- assert.equal(outcome.total, 0);
354
- });
355
- });
356
-
357
299
  describe("honestProgress", () => {
358
- // Regression for #109: `countMatches` and `runPredicateAction` page the
359
- // same predicate independently, so the delete can outrun the frozen
360
- // `total` it started with when the result set grows in between.
300
+ // Regression for #109: the count and `runPredicateAction` resolve the same
301
+ // predicate independently, so the delete can outrun the frozen `total` it
302
+ // started with when the result set grows in between.
361
303
  test("leaves an on-track progress reading untouched", () => {
362
304
  assert.deepEqual(honestProgress({ done: 50, total: 100 }), {
363
305
  done: 50,
@@ -214,14 +214,14 @@ export const runPredicateAction = async (
214
214
 
215
215
  /**
216
216
  * Corrects a progress reading so its `total` can never read as less than
217
- * `done` (#109). `runPredicateAction`'s `total` is `countMatches`'s frozen
218
- * page-through, taken before the run re-pages the same predicate a second,
219
- * independent time; if more matches arrived in between, `done` can overtake
220
- * it mid-run and a raw "Deleting 1,340 of 1,284" would follow. Widening the
221
- * denominator to match keeps the bar's ratio sane (never past 100%) without
222
- * claiming the original count was exact — the honest fix is admitting the
223
- * estimate grew, not re-paging to reconcile it (the result set is live; a
224
- * second count taken any later is no less stale than the first).
217
+ * `done` (#109). `runPredicateAction`'s `total` is the count the server gave
218
+ * before the run resolved the same predicate a second, independent time; if
219
+ * more matches arrived in between, `done` can overtake it mid-run and a raw
220
+ * "Deleting 1,340 of 1,284" would follow. Widening the denominator to match
221
+ * keeps the bar's ratio sane (never past 100%) without claiming the original
222
+ * count was exact — the honest fix is admitting the reading grew, not taking a
223
+ * second count to reconcile it (the result set is live; a count taken any
224
+ * later is no less stale than the first).
225
225
  */
226
226
  export const honestProgress = (
227
227
  progress: BulkActionProgress,
@@ -230,45 +230,6 @@ export const honestProgress = (
230
230
  total: Math.max(progress.total, progress.done),
231
231
  });
232
232
 
233
- export interface CountMatchesResult {
234
- total: number;
235
- cancelled: boolean;
236
- error?: unknown;
237
- }
238
-
239
- /**
240
- * Pages the full predicate result set to find its exact total — the only way,
241
- * short of a server-side total (out of scope, see issue #92), since search has
242
- * no total-count field beyond a small capped-window estimate. Reports a
243
- * running count via `onProgress` so a long count reads as progressing rather
244
- * than hung, and checks `isCancelled` between pages so Stop takes effect
245
- * within one page's latency.
246
- */
247
- export const countMatches = async (
248
- fetchIdsPage: FetchIdsPage,
249
- onProgress: (countSoFar: number) => void,
250
- isCancelled: () => boolean,
251
- ): Promise<CountMatchesResult> => {
252
- let total = 0;
253
- let token: string | undefined;
254
-
255
- do {
256
- if (isCancelled()) {
257
- return { total, cancelled: true };
258
- }
259
- const fetched = await attempt(fetchIdsPage(token));
260
- if (!fetched.ok) {
261
- return { total, cancelled: false, error: fetched.error };
262
- }
263
- const page = fetched.value;
264
- total += page.ids.length;
265
- onProgress(total);
266
- token = page.continuationToken;
267
- } while (token);
268
-
269
- return { total, cancelled: false };
270
- };
271
-
272
233
  export interface BulkRunOutcome {
273
234
  done: number;
274
235
  failedIds: string[];