@remit/web-client 0.0.223 → 0.0.224

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.223",
3
+ "version": "0.0.224",
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": {
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { type SyncStatusReading, waitForSettled } from "./useRefreshControl.js";
4
+
5
+ const BASELINE = 1_700_000_000_000;
6
+ const LATER = BASELINE + 5_000;
7
+
8
+ /** Replays `readings` in order and holds on the last one, counting reads so a
9
+ * test can say when the wait made up its mind. */
10
+ const scriptedReader = (readings: readonly SyncStatusReading[]) => {
11
+ const state = { reads: 0 };
12
+ const readStatus = async (): Promise<SyncStatusReading> => {
13
+ const reading = readings[Math.min(state.reads, readings.length - 1)];
14
+ state.reads += 1;
15
+ if (!reading) throw new Error("no reading scripted");
16
+ return reading;
17
+ };
18
+ return { state, readStatus };
19
+ };
20
+
21
+ const wait = (
22
+ readings: readonly SyncStatusReading[],
23
+ deadline = Date.now() + 10_000,
24
+ ) => {
25
+ const { state, readStatus } = scriptedReader(readings);
26
+ return {
27
+ state,
28
+ outcome: waitForSettled({
29
+ accountId: "a-1",
30
+ readStatus,
31
+ baselineLastSyncAt: BASELINE,
32
+ deadline,
33
+ pollMs: 0,
34
+ }),
35
+ };
36
+ };
37
+
38
+ describe("waitForSettled", () => {
39
+ // #953: the tab's own background poll, another tab, and every GET /config
40
+ // start rounds the user never clicked for. Seeing one of those in flight and
41
+ // then complete used to be read as confirmation, so the button showed a
42
+ // checkmark and invalidated the list with the clicked round still unrun —
43
+ // new mail stayed unshown until the next poll. Only the account's own
44
+ // round-start stamp moving past the pre-trigger reading counts.
45
+ test("does not confirm on a round that was already in flight at the trigger", async () => {
46
+ const { state, outcome } = wait([
47
+ { syncPhase: "syncing_inbox", lastSyncAt: BASELINE },
48
+ { syncPhase: "complete", lastSyncAt: BASELINE },
49
+ { syncPhase: "complete", lastSyncAt: LATER },
50
+ ]);
51
+
52
+ assert.equal(await outcome, undefined);
53
+ assert.equal(state.reads, 3);
54
+ });
55
+
56
+ test("a settled foreign round times out rather than confirming", async () => {
57
+ const { outcome } = wait(
58
+ [{ syncPhase: "complete", lastSyncAt: BASELINE }],
59
+ Date.now() - 1,
60
+ );
61
+
62
+ assert.deepEqual(await outcome, {
63
+ accountId: "a-1",
64
+ message: "Refresh is taking longer than usual",
65
+ });
66
+ });
67
+
68
+ test("confirms once the account's own round has started and settled", async () => {
69
+ const { state, outcome } = wait([
70
+ { syncPhase: "complete", lastSyncAt: LATER },
71
+ ]);
72
+
73
+ assert.equal(await outcome, undefined);
74
+ assert.equal(state.reads, 1);
75
+ });
76
+
77
+ test("reports the failure of a round that started after the trigger", async () => {
78
+ const { outcome } = wait([{ syncPhase: "error", lastSyncAt: LATER }]);
79
+
80
+ assert.deepEqual(await outcome, {
81
+ accountId: "a-1",
82
+ message: "Sync failed for this account",
83
+ });
84
+ });
85
+
86
+ // The stale-phase half of the same rule: POST /sync only enqueues, so the
87
+ // first reading back can still carry the previous round's error phase.
88
+ test("does not borrow the error phase of a round that predates the trigger", async () => {
89
+ const { outcome } = wait(
90
+ [{ syncPhase: "error", lastSyncAt: BASELINE }],
91
+ Date.now() - 1,
92
+ );
93
+
94
+ assert.deepEqual(await outcome, {
95
+ accountId: "a-1",
96
+ message: "Refresh is taking longer than usual",
97
+ });
98
+ });
99
+
100
+ test("an account that has never synced confirms on its first stamp", async () => {
101
+ const { state, readStatus } = scriptedReader([
102
+ { syncPhase: "idle", lastSyncAt: undefined },
103
+ { syncPhase: "complete", lastSyncAt: LATER },
104
+ ]);
105
+
106
+ const outcome = await waitForSettled({
107
+ accountId: "a-1",
108
+ readStatus,
109
+ baselineLastSyncAt: 0,
110
+ deadline: Date.now() + 10_000,
111
+ pollMs: 0,
112
+ });
113
+
114
+ assert.equal(outcome, undefined);
115
+ assert.equal(state.reads, 2);
116
+ });
117
+
118
+ test("surfaces a failed status read", async () => {
119
+ const outcome = await waitForSettled({
120
+ accountId: "a-1",
121
+ readStatus: () => Promise.reject(new Error("signed out")),
122
+ baselineLastSyncAt: BASELINE,
123
+ deadline: Date.now() + 10_000,
124
+ pollMs: 0,
125
+ });
126
+
127
+ assert.deepEqual(outcome, { accountId: "a-1", message: "signed out" });
128
+ });
129
+ });
@@ -3,7 +3,7 @@ import {
3
3
  syncOperationsGetSyncStatusOptions,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import { syncOperationsTriggerSync } from "@remit/api-http-client/sdk.gen.ts";
6
- import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
6
+ import type { RemitImapSyncPhase } from "@remit/api-http-client/types.gen.ts";
7
7
  import type { RefreshControlState } from "@remit/ui";
8
8
  import { type QueryClient, useQueryClient } from "@tanstack/react-query";
9
9
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -41,14 +41,6 @@ const escalateIfFatal = (error: unknown): void => {
41
41
  }
42
42
  };
43
43
 
44
- const maxLastSynced = (
45
- mailboxes: readonly RemitImapMailboxSyncProgress[],
46
- ): number =>
47
- mailboxes.reduce(
48
- (max, mailbox) => Math.max(max, mailbox.lastSyncedAt ?? 0),
49
- 0,
50
- );
51
-
52
44
  /**
53
45
  * `getSyncStatus` for one account, always as a real network read: `staleTime:
54
46
  * 0` means even a reading `MailFreshnessProvider` fetched a moment ago is
@@ -71,59 +63,74 @@ const fetchStatus = (queryClient: QueryClient, accountId: string) =>
71
63
  staleTime: 0,
72
64
  });
73
65
 
74
- interface AccountOutcome {
66
+ export interface AccountOutcome {
75
67
  accountId: string;
76
68
  message?: string;
77
69
  }
78
70
 
71
+ /** The two fields of `getSyncStatus` that identify a round, named so the wait
72
+ * below can be driven from a scripted sequence of readings. */
73
+ export interface SyncStatusReading {
74
+ syncPhase?: RemitImapSyncPhase;
75
+ lastSyncAt?: number;
76
+ }
77
+
78
+ export interface WaitForSettledOptions {
79
+ accountId: string;
80
+ readStatus: () => Promise<SyncStatusReading>;
81
+ /** `lastSyncAt` as it read immediately before this refresh triggered. */
82
+ baselineLastSyncAt: number;
83
+ deadline: number;
84
+ pollMs?: number;
85
+ }
86
+
79
87
  /**
80
- * Poll one account's sync status until a round that started after
81
- * `baselineMaxLastSynced` was captured has settled, or `deadline` passes.
88
+ * Poll one account's sync status until a round that started at or after this
89
+ * refresh triggered has settled, or `deadline` passes.
82
90
  *
83
- * `POST /sync` only enqueues the round (the worker runs it later), so the
84
- * very first status read after triggering can still show the *previous*
85
- * round's phase — reading that as "settled" would report success, or a
86
- * stale failed phase, before the new round ever ran (#582 review). This
87
- * waits for positive evidence the triggered round actually happened: either
88
- * the phase was observed mid-flight, or some mailbox's `lastSyncedAt`
89
- * advanced past the baseline taken before the trigger.
91
+ * `account.lastSyncAt` is the worker's own once-per-round stamp, and it is the
92
+ * only thing here that tells one round from another. A phase seen mid-flight
93
+ * does not: the tab's background poll, another tab, and every `GET /config`
94
+ * trigger rounds nobody clicked for, so a click landing while one of those is
95
+ * in flight used to confirm on it — a checkmark and an invalidated list with
96
+ * the clicked round still unrun, and the new mail it would have fetched unseen
97
+ * until the next poll (#953). Requiring the stamp to pass the reading taken
98
+ * before the trigger admits only a round that started after it.
99
+ *
100
+ * `POST /sync` enqueues and returns, so the first reading after triggering
101
+ * still shows the previous round — including a previous round's `error` phase,
102
+ * which is why that phase only speaks once the stamp has moved.
90
103
  *
91
104
  * Read-only (`getSyncStatus`, no IMAP call, no queue write), so waiting costs
92
105
  * a handful of cheap GETs — never a refetch of the account's own message
93
106
  * list.
94
107
  */
95
- const waitForSettled = async (
96
- queryClient: QueryClient,
97
- accountId: string,
98
- baselineMaxLastSynced: number,
99
- deadline: number,
100
- ): Promise<AccountOutcome | undefined> => {
101
- let observedInProgress = false;
108
+ export const waitForSettled = async ({
109
+ accountId,
110
+ readStatus,
111
+ baselineLastSyncAt,
112
+ deadline,
113
+ pollMs = REFRESH_POLL_MS,
114
+ }: WaitForSettledOptions): Promise<AccountOutcome | undefined> => {
102
115
  for (;;) {
103
- const outcome = await fetchStatus(queryClient, accountId)
116
+ const outcome = await readStatus()
104
117
  .then((data) => ({ ok: true as const, data }))
105
118
  .catch((error: unknown) => ({ ok: false as const, error }));
106
119
  if (!outcome.ok) {
107
120
  return { accountId, message: messageFor(outcome.error) };
108
121
  }
109
- const { syncPhase, mailboxes } = outcome.data;
110
- if (isSyncingPhase(syncPhase)) {
111
- observedInProgress = true;
112
- } else {
113
- const confirmed =
114
- observedInProgress ||
115
- maxLastSynced(mailboxes ?? []) > baselineMaxLastSynced;
116
- if (confirmed) {
117
- if (syncPhase === "error") {
118
- return { accountId, message: "Sync failed for this account" };
119
- }
120
- return undefined;
122
+ const { syncPhase, lastSyncAt } = outcome.data;
123
+ const startedAfterTrigger = (lastSyncAt ?? 0) > baselineLastSyncAt;
124
+ if (startedAfterTrigger && !isSyncingPhase(syncPhase)) {
125
+ if (syncPhase === "error") {
126
+ return { accountId, message: "Sync failed for this account" };
121
127
  }
128
+ return undefined;
122
129
  }
123
130
  if (Date.now() >= deadline) {
124
131
  return { accountId, message: "Refresh is taking longer than usual" };
125
132
  }
126
- await sleep(REFRESH_POLL_MS);
133
+ await sleep(pollMs);
127
134
  }
128
135
  };
129
136
 
@@ -146,7 +153,7 @@ const refreshOneAccount = async (
146
153
  const baseline = await fetchStatus(queryClient, accountId)
147
154
  .then((data) => ({
148
155
  ok: true as const,
149
- maxLastSynced: maxLastSynced(data.mailboxes ?? []),
156
+ lastSyncAt: data.lastSyncAt ?? 0,
150
157
  }))
151
158
  .catch((error: unknown) => ({ ok: false as const, error }));
152
159
  if (!baseline.ok) {
@@ -175,12 +182,12 @@ const refreshOneAccount = async (
175
182
  }
176
183
  telemetry.recordEvent("sync.triggered");
177
184
 
178
- const settled = await waitForSettled(
179
- queryClient,
185
+ const settled = await waitForSettled({
180
186
  accountId,
181
- baseline.maxLastSynced,
187
+ readStatus: () => fetchStatus(queryClient, accountId),
188
+ baselineLastSyncAt: baseline.lastSyncAt,
182
189
  deadline,
183
- );
190
+ });
184
191
  if (settled)
185
192
  return { accountId, enqueued: true, ok: false, message: settled.message };
186
193
  return { accountId, enqueued: true, ok: true };