@remit/web-client 0.0.223 → 0.0.225
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.
|
|
3
|
+
"version": "0.0.225",
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* the
|
|
89
|
-
*
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
deadline
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -199,8 +199,8 @@ export const rulePredicate = (
|
|
|
199
199
|
* all. Without an anchor it takes the vector-free literal arm, which reads no
|
|
200
200
|
* message body and rejects a `HasWords` clause rather than narrowing the match
|
|
201
201
|
* silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
|
|
202
|
-
* anyway is refused
|
|
203
|
-
*
|
|
202
|
+
* anyway is refused with a 400, on preview and on a back-apply alike, so the
|
|
203
|
+
* caller must not ask.
|
|
204
204
|
*/
|
|
205
205
|
export const isEvaluablePredicate = (
|
|
206
206
|
predicate: OrganizeMatchPredicate,
|