@remit/web-client 0.0.198 → 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
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": {
|
|
@@ -58,6 +58,15 @@ const isSettledStatus = (status: string | undefined): boolean =>
|
|
|
58
58
|
*/
|
|
59
59
|
export const OUTBOX_ROW_META = softErrorStatuses(404);
|
|
60
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
|
+
|
|
61
70
|
export const ComposeProvider = ({
|
|
62
71
|
children,
|
|
63
72
|
}: {
|
|
@@ -76,8 +85,8 @@ export const ComposeProvider = ({
|
|
|
76
85
|
meta: OUTBOX_ROW_META,
|
|
77
86
|
retry: (failureCount, error) => !isNotFound(error) && failureCount < 1,
|
|
78
87
|
refetchInterval: (query) => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
88
|
+
if (isWatchOver(query.state.data?.status, query.state.error))
|
|
89
|
+
return false;
|
|
81
90
|
return POLL_INTERVAL_MS;
|
|
82
91
|
},
|
|
83
92
|
});
|
|
@@ -91,8 +100,7 @@ export const ComposeProvider = ({
|
|
|
91
100
|
|
|
92
101
|
useEffect(() => {
|
|
93
102
|
if (!pollingMessageId) return;
|
|
94
|
-
if (!
|
|
95
|
-
return;
|
|
103
|
+
if (!isWatchOver(polledMessage?.status, pollError)) return;
|
|
96
104
|
|
|
97
105
|
stopWatching();
|
|
98
106
|
}, [polledMessage, pollError, pollingMessageId, stopWatching]);
|
|
@@ -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
|
+
});
|