@remit/web-client 0.0.197 → 0.0.199
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/compose/ComposeForm.tsx +15 -3
- package/src/components/compose/ComposeProvider.tsx +52 -28
- package/src/components/compose/send-poll-server-error.render.test.ts +120 -0
- package/src/lib/error-classifier.test.ts +48 -0
- package/src/lib/error-classifier.ts +28 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.199",
|
|
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": {
|
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
41
41
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
42
42
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
43
|
-
import { softErrorMeta } from "../../lib/error-classifier";
|
|
43
|
+
import { isNotFound, softErrorMeta } from "../../lib/error-classifier";
|
|
44
44
|
import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
|
|
45
45
|
import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
|
|
46
46
|
import {
|
|
@@ -59,7 +59,7 @@ const LazyComposeBody = lazy(() =>
|
|
|
59
59
|
import { useIsDesktop } from "../../hooks/useMediaQuery.js";
|
|
60
60
|
import { useVisualViewport } from "../../hooks/useVisualViewport.js";
|
|
61
61
|
import type { ComposeMode } from "./ComposeProvider";
|
|
62
|
-
import { useCompose } from "./ComposeProvider";
|
|
62
|
+
import { OUTBOX_ROW_META, useCompose } from "./ComposeProvider";
|
|
63
63
|
import { FromSelector } from "./FromSelector";
|
|
64
64
|
|
|
65
65
|
interface ComposeFormProps {
|
|
@@ -458,13 +458,25 @@ export const ComposeForm = ({
|
|
|
458
458
|
formatting: [],
|
|
459
459
|
}));
|
|
460
460
|
|
|
461
|
-
const { data: draftData } = useQuery({
|
|
461
|
+
const { data: draftData, error: draftError } = useQuery({
|
|
462
462
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
463
463
|
path: { outboxMessageId: outboxMessageId ?? "" },
|
|
464
464
|
}),
|
|
465
465
|
enabled: !!outboxMessageId && !draftLoaded,
|
|
466
|
+
meta: OUTBOX_ROW_META,
|
|
467
|
+
retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
|
|
466
468
|
});
|
|
467
469
|
|
|
470
|
+
// The draft id is a path segment, so Back after a send — or a restored tab —
|
|
471
|
+
// reopens the composer on a row the send already filed and deleted. There is
|
|
472
|
+
// no document left to reopen and nothing failed, so the surface closes the way
|
|
473
|
+
// it closes on Send. Only the 404 is read this way; a lapsed session still
|
|
474
|
+
// escalates.
|
|
475
|
+
useEffect(() => {
|
|
476
|
+
if (!isNotFound(draftError)) return;
|
|
477
|
+
onClose();
|
|
478
|
+
}, [draftError, onClose]);
|
|
479
|
+
|
|
468
480
|
useEffect(() => {
|
|
469
481
|
if (!draftData || draftLoaded) return;
|
|
470
482
|
|
|
@@ -9,9 +9,9 @@ import {
|
|
|
9
9
|
useContext,
|
|
10
10
|
useEffect,
|
|
11
11
|
useMemo,
|
|
12
|
-
useRef,
|
|
13
12
|
useState,
|
|
14
13
|
} from "react";
|
|
14
|
+
import { isNotFound, softErrorStatuses } from "@/lib/error-classifier";
|
|
15
15
|
import type { ReplyMode } from "@/routing";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -41,6 +41,32 @@ const ComposeContext = createContext<ComposeContextValue | undefined>(
|
|
|
41
41
|
const POLL_INTERVAL_MS = 2000;
|
|
42
42
|
const MAX_POLL_DURATION_MS = 60_000;
|
|
43
43
|
|
|
44
|
+
const isSettledStatus = (status: string | undefined): boolean =>
|
|
45
|
+
status === "sent" ||
|
|
46
|
+
status === "unfiled" ||
|
|
47
|
+
status === "failed" ||
|
|
48
|
+
status === "blocked";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A send that lands is filed and then forgotten: the worker APPENDs the message
|
|
52
|
+
* to Sent and drops the outbox row, so the row's absence is the settled state
|
|
53
|
+
* and the 404 is the confirmation. `sent` lives for under a second and this poll
|
|
54
|
+
* runs every two, so the 404 is the outcome it normally reads.
|
|
55
|
+
*
|
|
56
|
+
* Only the 404 is this call site's to own. A 401 or a 403 here is a session that
|
|
57
|
+
* lapsed under a send, which the app owes the user an answer about.
|
|
58
|
+
*/
|
|
59
|
+
export const OUTBOX_ROW_META = softErrorStatuses(404);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The watch is over on a settled status and on any error the read comes back
|
|
63
|
+
* with. The 404 is the settled state itself; every other refusal has already
|
|
64
|
+
* escalated once through the query cache, and asking again every two seconds
|
|
65
|
+
* only repeats that escalation — a fresh fatal event per tick — until the cap.
|
|
66
|
+
*/
|
|
67
|
+
const isWatchOver = (status: string | undefined, error: unknown): boolean =>
|
|
68
|
+
isSettledStatus(status) || error != null;
|
|
69
|
+
|
|
44
70
|
export const ComposeProvider = ({
|
|
45
71
|
children,
|
|
46
72
|
}: {
|
|
@@ -49,48 +75,46 @@ export const ComposeProvider = ({
|
|
|
49
75
|
const [pollingMessageId, setPollingMessageId] = useState<
|
|
50
76
|
string | undefined
|
|
51
77
|
>();
|
|
52
|
-
const startedAtRef = useRef(0);
|
|
53
78
|
const queryClient = useQueryClient();
|
|
54
79
|
|
|
55
|
-
const { data: polledMessage } = useQuery({
|
|
80
|
+
const { data: polledMessage, error: pollError } = useQuery({
|
|
56
81
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
57
82
|
path: { outboxMessageId: pollingMessageId ?? "" },
|
|
58
83
|
}),
|
|
59
84
|
enabled: !!pollingMessageId,
|
|
85
|
+
meta: OUTBOX_ROW_META,
|
|
86
|
+
retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
|
|
60
87
|
refetchInterval: (query) => {
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
status === "sent" ||
|
|
64
|
-
status === "unfiled" ||
|
|
65
|
-
status === "failed" ||
|
|
66
|
-
status === "blocked"
|
|
67
|
-
)
|
|
68
|
-
return false;
|
|
69
|
-
if (Date.now() - startedAtRef.current > MAX_POLL_DURATION_MS)
|
|
88
|
+
if (isWatchOver(query.state.data?.status, query.state.error))
|
|
70
89
|
return false;
|
|
71
90
|
return POLL_INTERVAL_MS;
|
|
72
91
|
},
|
|
73
92
|
});
|
|
74
93
|
|
|
94
|
+
const stopWatching = useCallback(() => {
|
|
95
|
+
setPollingMessageId(undefined);
|
|
96
|
+
queryClient.invalidateQueries({
|
|
97
|
+
queryKey: outboxOperationsListOutboxMessagesQueryKey(),
|
|
98
|
+
});
|
|
99
|
+
}, [queryClient]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!pollingMessageId) return;
|
|
103
|
+
if (!isWatchOver(polledMessage?.status, pollError)) return;
|
|
104
|
+
|
|
105
|
+
stopWatching();
|
|
106
|
+
}, [polledMessage, pollError, pollingMessageId, stopWatching]);
|
|
107
|
+
|
|
108
|
+
// The cap has to put the watch down rather than only stop the interval: a
|
|
109
|
+
// query left enabled comes back on the next window focus, minutes later, to
|
|
110
|
+
// poll a send nobody is waiting on any more.
|
|
75
111
|
useEffect(() => {
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
polledMessage.status === "unfiled" ||
|
|
81
|
-
polledMessage.status === "failed" ||
|
|
82
|
-
polledMessage.status === "blocked";
|
|
83
|
-
|
|
84
|
-
if (terminal) {
|
|
85
|
-
setPollingMessageId(undefined);
|
|
86
|
-
queryClient.invalidateQueries({
|
|
87
|
-
queryKey: outboxOperationsListOutboxMessagesQueryKey(),
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}, [polledMessage, pollingMessageId, queryClient]);
|
|
112
|
+
if (!pollingMessageId) return;
|
|
113
|
+
const giveUp = setTimeout(stopWatching, MAX_POLL_DURATION_MS);
|
|
114
|
+
return () => clearTimeout(giveUp);
|
|
115
|
+
}, [pollingMessageId, stopWatching]);
|
|
91
116
|
|
|
92
117
|
const startSendPolling = useCallback((outboxMessageId: string) => {
|
|
93
|
-
startedAtRef.current = Date.now();
|
|
94
118
|
setPollingMessageId(outboxMessageId);
|
|
95
119
|
}, []);
|
|
96
120
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #924: the send watch decided its next tick from the polled status
|
|
3
|
+
* alone. A 5xx carries no status, so nothing matched, the interval stayed at two
|
|
4
|
+
* seconds, and every failed tick reached the query cache and escalated again —
|
|
5
|
+
* one overlay for the user, a fresh fatal event with a fresh correlation id for
|
|
6
|
+
* telemetry, for the whole minute the cap allowed.
|
|
7
|
+
*
|
|
8
|
+
* A poll the server refuses is over. It escalates once, the watch comes down,
|
|
9
|
+
* and the outbox list is invalidated so the row's real state is what the user
|
|
10
|
+
* reads next.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { afterEach, describe, it } from "node:test";
|
|
15
|
+
import { outboxOperationsListOutboxMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
16
|
+
import { QueryCache, QueryClient } from "@tanstack/react-query";
|
|
17
|
+
import { createElement, useEffect } from "react";
|
|
18
|
+
import { __resetFatalError, subscribeFatalError } from "../../lib/fatal-error";
|
|
19
|
+
import { handleQueryCacheError } from "../../lib/query-error-handler";
|
|
20
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
21
|
+
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
22
|
+
import { ComposeProvider, useCompose } from "./ComposeProvider";
|
|
23
|
+
|
|
24
|
+
const OUTBOX_MESSAGE_ID = "ob-924";
|
|
25
|
+
const POLL_INTERVAL_MS = 2000;
|
|
26
|
+
|
|
27
|
+
let harness: DomHarness | undefined;
|
|
28
|
+
let http: HttpMock | undefined;
|
|
29
|
+
let fatals = 0;
|
|
30
|
+
let unsubscribe: (() => void) | undefined;
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
harness?.close();
|
|
34
|
+
harness = undefined;
|
|
35
|
+
http?.restore();
|
|
36
|
+
http = undefined;
|
|
37
|
+
unsubscribe?.();
|
|
38
|
+
unsubscribe = undefined;
|
|
39
|
+
fatals = 0;
|
|
40
|
+
__resetFatalError();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const polls = (): number =>
|
|
44
|
+
(http?.calls ?? []).filter(
|
|
45
|
+
(call) => call.method === "GET" && call.path.endsWith(OUTBOX_MESSAGE_ID),
|
|
46
|
+
).length;
|
|
47
|
+
|
|
48
|
+
const outboxListIsStale = (): boolean =>
|
|
49
|
+
harness?.queryClient
|
|
50
|
+
.getQueryCache()
|
|
51
|
+
.find({ queryKey: outboxOperationsListOutboxMessagesQueryKey() })?.state
|
|
52
|
+
.isInvalidated === true;
|
|
53
|
+
|
|
54
|
+
const Sender = () => {
|
|
55
|
+
const { startSendPolling } = useCompose();
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
startSendPolling(OUTBOX_MESSAGE_ID);
|
|
59
|
+
}, [startSendPolling]);
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mount = async (pollStatus: number): Promise<void> => {
|
|
65
|
+
http = mockFetch(async (call) => {
|
|
66
|
+
if (call.path.endsWith(OUTBOX_MESSAGE_ID)) {
|
|
67
|
+
return httpError(pollStatus, "the outbox is having a moment");
|
|
68
|
+
}
|
|
69
|
+
return { messages: [] };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
unsubscribe = subscribeFatalError(() => {
|
|
73
|
+
fatals += 1;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const queryClient = new QueryClient({
|
|
77
|
+
queryCache: new QueryCache({ onError: handleQueryCacheError }),
|
|
78
|
+
// The watch sets its own retry policy; a zero delay only spares the test
|
|
79
|
+
// the backoff it would otherwise sit through.
|
|
80
|
+
defaultOptions: { queries: { retryDelay: 0 }, mutations: { retry: false } },
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
harness = createDomHarness({ queryClient });
|
|
84
|
+
queryClient.setQueryData(outboxOperationsListOutboxMessagesQueryKey(), {
|
|
85
|
+
messages: [],
|
|
86
|
+
});
|
|
87
|
+
harness.renderApp(
|
|
88
|
+
createElement(ComposeProvider, null, createElement(Sender)),
|
|
89
|
+
);
|
|
90
|
+
await harness.flush();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
describe("a send watch the server refuses (#924)", () => {
|
|
94
|
+
it("escalates once and stops polling", async () => {
|
|
95
|
+
await mount(500);
|
|
96
|
+
await harness?.wait(POLL_INTERVAL_MS * 3);
|
|
97
|
+
|
|
98
|
+
assert.equal(fatals, 1, "the refusal escalated exactly once");
|
|
99
|
+
assert.equal(
|
|
100
|
+
polls(),
|
|
101
|
+
2,
|
|
102
|
+
"the read was attempted once and retried once, then the watch came down",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("invalidates the outbox list so the row's real state is on screen", async () => {
|
|
107
|
+
await mount(500);
|
|
108
|
+
await harness?.wait(POLL_INTERVAL_MS);
|
|
109
|
+
|
|
110
|
+
assert.ok(outboxListIsStale(), "the outbox list was marked for a refetch");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("leaves the settled 404 alone — one stop, no escalation", async () => {
|
|
114
|
+
await mount(404);
|
|
115
|
+
await harness?.wait(POLL_INTERVAL_MS * 2);
|
|
116
|
+
|
|
117
|
+
assert.equal(fatals, 0, "the deleted row is the confirmation, not a fault");
|
|
118
|
+
assert.equal(polls(), 1, "a 404 is not retried and not polled again");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -7,8 +7,10 @@ import {
|
|
|
7
7
|
isAbortError,
|
|
8
8
|
isClientBug,
|
|
9
9
|
isNetworkError,
|
|
10
|
+
isNotFound,
|
|
10
11
|
isServerError,
|
|
11
12
|
shouldEscalate,
|
|
13
|
+
softErrorStatuses,
|
|
12
14
|
} from "./error-classifier";
|
|
13
15
|
import { NetworkError, taggedFetch } from "./network-error";
|
|
14
16
|
|
|
@@ -325,3 +327,49 @@ describe("shouldEscalate (the fail-fast decision table — #1059)", () => {
|
|
|
325
327
|
);
|
|
326
328
|
});
|
|
327
329
|
});
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The send watch reads a row the server deletes on success, so the 404 is the
|
|
333
|
+
* outcome it is there to see. Everything else that endpoint can answer is still
|
|
334
|
+
* news — a lapsed session most of all, because a background read that renders it
|
|
335
|
+
* as nothing leaves the user on a screen that has quietly stopped working.
|
|
336
|
+
*/
|
|
337
|
+
describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
338
|
+
const ownsNotFound = softErrorStatuses(404);
|
|
339
|
+
|
|
340
|
+
it("does NOT escalate the status the call site named", () => {
|
|
341
|
+
assert.equal(
|
|
342
|
+
shouldEscalate(new ApiError("gone", 404), ownsNotFound, "nobody"),
|
|
343
|
+
false,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("escalates every other 4xx, the lapsed session included", () => {
|
|
348
|
+
assert.equal(
|
|
349
|
+
shouldEscalate(new ApiError("signed out", 401), ownsNotFound, "nobody"),
|
|
350
|
+
true,
|
|
351
|
+
);
|
|
352
|
+
assert.equal(
|
|
353
|
+
shouldEscalate(new ApiError("not yours", 403), ownsNotFound, "nobody"),
|
|
354
|
+
true,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("escalates a 5xx and a client bug, as every opt-out does", () => {
|
|
359
|
+
assert.equal(
|
|
360
|
+
shouldEscalate(new ApiError("boom", 500), ownsNotFound, "nobody"),
|
|
361
|
+
true,
|
|
362
|
+
);
|
|
363
|
+
assert.equal(
|
|
364
|
+
shouldEscalate(new TypeError("x is undefined"), ownsNotFound, "nobody"),
|
|
365
|
+
true,
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("names the record's absence, whichever client raised it", () => {
|
|
370
|
+
assert.equal(isNotFound(new ApiError("gone", 404)), true);
|
|
371
|
+
assert.equal(isNotFound({ status: 404 }), true);
|
|
372
|
+
assert.equal(isNotFound(new ApiError("gone", 410)), false);
|
|
373
|
+
assert.equal(isNotFound(new TypeError("x is undefined")), false);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -132,8 +132,16 @@ export const isUnauthenticated = (error: unknown): boolean =>
|
|
|
132
132
|
*/
|
|
133
133
|
export type Awaiting = "user" | "nobody";
|
|
134
134
|
|
|
135
|
-
const isSoftErrorMeta = (
|
|
136
|
-
|
|
135
|
+
const isSoftErrorMeta = (
|
|
136
|
+
error: unknown,
|
|
137
|
+
meta: Record<string, unknown> | undefined,
|
|
138
|
+
): boolean => {
|
|
139
|
+
if (meta?.softError === true) return true;
|
|
140
|
+
const owned = meta?.softErrorStatuses;
|
|
141
|
+
if (!Array.isArray(owned)) return false;
|
|
142
|
+
const status = getErrorStatus(error);
|
|
143
|
+
return status !== undefined && owned.includes(status);
|
|
144
|
+
};
|
|
137
145
|
|
|
138
146
|
/**
|
|
139
147
|
* The single fail-fast decision: should this error escalate to the full-screen
|
|
@@ -157,7 +165,8 @@ const isSoftErrorMeta = (meta: Record<string, unknown> | undefined): boolean =>
|
|
|
157
165
|
* reconnect/retry;
|
|
158
166
|
* c. a non-5xx error on a query/mutation that opted out via
|
|
159
167
|
* `meta.softError === true` — the call site owns that error's UX
|
|
160
|
-
* (e.g. a 404 empty state, a 4xx "Reconnect" banner)
|
|
168
|
+
* (e.g. a 404 empty state, a 4xx "Reconnect" banner) — or, where it owns
|
|
169
|
+
* one status and not the rest, `meta.softErrorStatuses`.
|
|
161
170
|
*/
|
|
162
171
|
export const shouldEscalate = (
|
|
163
172
|
error: unknown,
|
|
@@ -169,7 +178,7 @@ export const shouldEscalate = (
|
|
|
169
178
|
if (isNetworkError(error)) return false;
|
|
170
179
|
if (awaiting === "user" && isUnauthenticated(error)) return true;
|
|
171
180
|
if (isAlwaysFatal(error)) return true;
|
|
172
|
-
if (isSoftErrorMeta(meta)) return false;
|
|
181
|
+
if (isSoftErrorMeta(error, meta)) return false;
|
|
173
182
|
return true;
|
|
174
183
|
};
|
|
175
184
|
|
|
@@ -188,3 +197,18 @@ export const shouldEscalate = (
|
|
|
188
197
|
* than the failure it reports.
|
|
189
198
|
*/
|
|
190
199
|
export const softErrorMeta: { softError: true } = { softError: true };
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The narrow form, for a call site that owns one outcome and none of the others.
|
|
203
|
+
* A read whose record may legitimately be gone owns the 404 and nothing else: a
|
|
204
|
+
* blanket `softErrorMeta` there would also swallow the 401 and the 403, and a
|
|
205
|
+
* background read that answers a lapsed session with nothing leaves the user
|
|
206
|
+
* looking at a screen that has quietly stopped working.
|
|
207
|
+
*/
|
|
208
|
+
export const softErrorStatuses = (
|
|
209
|
+
...statuses: number[]
|
|
210
|
+
): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
|
|
211
|
+
|
|
212
|
+
/** The record is not there. */
|
|
213
|
+
export const isNotFound = (error: unknown): boolean =>
|
|
214
|
+
getErrorStatus(error) === 404;
|