@12-apps/payments-frontend 3.21.2 → 3.21.4
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": "@12-apps/payments-frontend",
|
|
3
|
-
"version": "3.21.
|
|
3
|
+
"version": "3.21.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Result } from "../../result";
|
|
2
|
+
import { claimRearm } from "./poll-rearm";
|
|
2
3
|
import { TERMINAL_STATUSES, type OrderStatus } from "./types";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -18,6 +19,14 @@ export interface PollingOptions {
|
|
|
18
19
|
* {@link DEFAULT_ASK_TIMEOUT_MS}; consumers override it only in tests.
|
|
19
20
|
*/
|
|
20
21
|
askTimeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* What the BUYER reads when an ask is abandoned for hanging.
|
|
24
|
+
*
|
|
25
|
+
* The hook passes the transport's own `copy.offline`, so a dead socket reads
|
|
26
|
+
* as the dropped connection it is. Optional only so a direct caller of
|
|
27
|
+
* `createPollLoop` need not thread copy it does not have.
|
|
28
|
+
*/
|
|
29
|
+
askTimeoutError?: string;
|
|
21
30
|
/** Poll only while `true` (e.g. after a card charge is submitted). */
|
|
22
31
|
enabled?: boolean;
|
|
23
32
|
/**
|
|
@@ -62,16 +71,6 @@ export interface PollingOptions {
|
|
|
62
71
|
*/
|
|
63
72
|
const MAX_ERROR_BACKOFF_MS = 10_000;
|
|
64
73
|
|
|
65
|
-
/**
|
|
66
|
-
* How long a re-arm must stay quiet after the last ask.
|
|
67
|
-
*
|
|
68
|
-
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
69
|
-
* and a shopper flicking between the bank app and the store fires them again
|
|
70
|
-
* per trip. This collapses a burst into one request without delaying it: the
|
|
71
|
-
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
72
|
-
*/
|
|
73
|
-
const REARM_QUIET_MS = 1_000;
|
|
74
|
-
|
|
75
74
|
/**
|
|
76
75
|
* How long ONE status ask may take before the loop stops waiting on it.
|
|
77
76
|
*
|
|
@@ -83,9 +82,10 @@ const REARM_QUIET_MS = 1_000;
|
|
|
83
82
|
const DEFAULT_ASK_TIMEOUT_MS = 15_000;
|
|
84
83
|
|
|
85
84
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
85
|
+
* Last resort only, for a host that wired the loop without `askTimeoutError`.
|
|
86
|
+
* `Result.error` is what the BUYER reads — the PIX, card and wallet panels all
|
|
87
|
+
* render it verbatim — so the hook passes the transport's own `copy.offline`
|
|
88
|
+
* down and a hang reads as the dropped connection it is.
|
|
89
89
|
*/
|
|
90
90
|
const ASK_TIMED_OUT = "timeout";
|
|
91
91
|
|
|
@@ -161,7 +161,17 @@ function newRun() {
|
|
|
161
161
|
healthy: 0,
|
|
162
162
|
startedAt: 0,
|
|
163
163
|
askedAt: 0,
|
|
164
|
+
/** Live asks abandoned by a re-arm since the last answer. See `claimRearm`. */
|
|
165
|
+
supersededAsks: 0,
|
|
164
166
|
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
167
|
+
/**
|
|
168
|
+
* The wall clock, as a timer rather than a check after an ask. `outOfTime`
|
|
169
|
+
* needs an ask to RETURN, and `askTimeout` stands down when its attempt is
|
|
170
|
+
* superseded — which every `poke` does. A shopper flicking to their bank
|
|
171
|
+
* app faster than `askTimeoutMs` refreshed it forever, so `maxWaitMs` never
|
|
172
|
+
* fired and the hosted return span with no error and no check-again button.
|
|
173
|
+
*/
|
|
174
|
+
deadline: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
165
175
|
};
|
|
166
176
|
}
|
|
167
177
|
|
|
@@ -183,7 +193,9 @@ function askTimeout(
|
|
|
183
193
|
): Promise<Result<OrderStatus>> {
|
|
184
194
|
return new Promise((resolve) => {
|
|
185
195
|
setTimeout(() => {
|
|
186
|
-
if (run.attempt === mine)
|
|
196
|
+
if (run.attempt === mine) {
|
|
197
|
+
resolve({ ok: false, error: options.askTimeoutError ?? ASK_TIMED_OUT });
|
|
198
|
+
}
|
|
187
199
|
}, options.askTimeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
|
|
188
200
|
});
|
|
189
201
|
}
|
|
@@ -196,6 +208,9 @@ function askTimeout(
|
|
|
196
208
|
* it has to be consulted against the delay it is about to sleep for.
|
|
197
209
|
*/
|
|
198
210
|
function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
|
|
211
|
+
// An answer got through, so the re-arm budget is no longer being spent into
|
|
212
|
+
// a void: refill it whether the answer was an error or a status.
|
|
213
|
+
run.supersededAsks = 0;
|
|
199
214
|
if (!result.ok) {
|
|
200
215
|
run.errors += 1;
|
|
201
216
|
sink.setError(result.error);
|
|
@@ -213,6 +228,88 @@ function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): bool
|
|
|
213
228
|
return true;
|
|
214
229
|
}
|
|
215
230
|
|
|
231
|
+
/** Cancel whatever tick is scheduled, if any. */
|
|
232
|
+
function clearPending(run: PollRun): void {
|
|
233
|
+
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
234
|
+
run.timer = undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Cancel the wall clock, if it is armed. */
|
|
238
|
+
function clearDeadline(run: PollRun): void {
|
|
239
|
+
if (run.deadline !== undefined) clearTimeout(run.deadline);
|
|
240
|
+
run.deadline = undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The wall clock, armed once per run by `restart`, so `maxWaitMs` holds whether
|
|
245
|
+
* or not an ask ever returns. `outOfTime` stays too: it ends the wait one delay
|
|
246
|
+
* EARLIER when asks ARE returning. This is the backstop for when they are not.
|
|
247
|
+
*/
|
|
248
|
+
function armDeadline(run: PollRun, options: PollingOptions, sink: PollSink): void {
|
|
249
|
+
clearDeadline(run);
|
|
250
|
+
if (options.maxWaitMs === undefined) return;
|
|
251
|
+
run.deadline = setTimeout(() => {
|
|
252
|
+
run.deadline = undefined;
|
|
253
|
+
if (run.cancelled || run.settled) return;
|
|
254
|
+
run.stopped = true;
|
|
255
|
+
clearPending(run);
|
|
256
|
+
sink.setTimedOut(true);
|
|
257
|
+
}, options.maxWaitMs);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* One ask, bounded by {@link askTimeout} and guaranteed not to throw. `race`
|
|
262
|
+
* rejects the instant either input does, so an `ask` that throws — a host
|
|
263
|
+
* client wrapping ours — left `inFlight` true with nothing scheduled: the very
|
|
264
|
+
* wedge this file removes, through the one door the race does not close.
|
|
265
|
+
*/
|
|
266
|
+
async function askOnce(
|
|
267
|
+
ask: () => Promise<Result<OrderStatus>>,
|
|
268
|
+
run: PollRun,
|
|
269
|
+
mine: number,
|
|
270
|
+
options: PollingOptions,
|
|
271
|
+
): Promise<Result<OrderStatus>> {
|
|
272
|
+
try {
|
|
273
|
+
return await Promise.race([ask(), askTimeout(run, mine, options)]);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const fallback = options.askTimeoutError ?? ASK_TIMED_OUT;
|
|
276
|
+
return { ok: false, error: error instanceof Error ? error.message : fallback };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
|
|
281
|
+
function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
|
|
282
|
+
return options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Whether this attempt's answer may still be written. A superseded one writes
|
|
287
|
+
* nothing — it may be a hung request finally answering — EXCEPT a terminal
|
|
288
|
+
* status, which is the answer the wait exists for, is idempotent, and would
|
|
289
|
+
* otherwise be dropped because a re-arm landed first.
|
|
290
|
+
*/
|
|
291
|
+
function mayWrite(run: PollRun, mine: number, result: Result<OrderStatus>): boolean {
|
|
292
|
+
return run.attempt === mine || (result.ok && TERMINAL_STATUSES.includes(result.data));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Book the next tick, or end the wait because its clock has run out. */
|
|
296
|
+
function scheduleNext(
|
|
297
|
+
run: PollRun,
|
|
298
|
+
options: PollingOptions,
|
|
299
|
+
sink: PollSink,
|
|
300
|
+
again: () => void,
|
|
301
|
+
): void {
|
|
302
|
+
const delay = pollDelay(run.healthy, run.errors, options);
|
|
303
|
+
if (outOfTime(run, options, delay)) {
|
|
304
|
+
run.stopped = true;
|
|
305
|
+
clearDeadline(run);
|
|
306
|
+
sink.setTimedOut(true);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
clearPending(run);
|
|
310
|
+
run.timer = setTimeout(again, delay);
|
|
311
|
+
}
|
|
312
|
+
|
|
216
313
|
/** The handle the hook holds on one running wait. */
|
|
217
314
|
export interface PollLoop {
|
|
218
315
|
/** Reset the clock and the counters, then ask immediately. */
|
|
@@ -238,13 +335,6 @@ export function createPollLoop(
|
|
|
238
335
|
): PollLoop {
|
|
239
336
|
const run = newRun();
|
|
240
337
|
|
|
241
|
-
const clearPending = (): void => {
|
|
242
|
-
if (run.timer !== undefined) clearTimeout(run.timer);
|
|
243
|
-
run.timer = undefined;
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const outOfTime = (delay: number): boolean =>
|
|
247
|
-
options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
|
|
248
338
|
|
|
249
339
|
const tick = async (): Promise<void> => {
|
|
250
340
|
if (run.cancelled || run.settled || run.inFlight) return;
|
|
@@ -256,27 +346,22 @@ export function createPollLoop(
|
|
|
256
346
|
// is supposed to end after `maxWaitMs` runs forever showing "we are still
|
|
257
347
|
// trying". Racing a timer turns a hang into an ordinary failed poll, which
|
|
258
348
|
// the backoff and the re-arm already know how to handle.
|
|
259
|
-
const result = await
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (run.attempt !== mine) return;
|
|
263
|
-
run.inFlight = false;
|
|
349
|
+
const result = await askOnce(ask, run, mine, options);
|
|
350
|
+
if (!mayWrite(run, mine, result)) return;
|
|
351
|
+
if (run.attempt === mine) run.inFlight = false;
|
|
264
352
|
if (run.cancelled || run.settled) return;
|
|
265
|
-
if (!absorb(run, result, sink))
|
|
266
|
-
|
|
267
|
-
if (outOfTime(delay)) {
|
|
268
|
-
run.stopped = true;
|
|
269
|
-
sink.setTimedOut(true);
|
|
353
|
+
if (!absorb(run, result, sink)) {
|
|
354
|
+
clearDeadline(run);
|
|
270
355
|
return;
|
|
271
356
|
}
|
|
272
|
-
|
|
273
|
-
run
|
|
357
|
+
if (run.attempt !== mine) return;
|
|
358
|
+
scheduleNext(run, options, sink, () => void tick());
|
|
274
359
|
};
|
|
275
360
|
|
|
276
361
|
return {
|
|
277
362
|
restart: (): void => {
|
|
278
363
|
if (run.cancelled || run.settled) return;
|
|
279
|
-
clearPending();
|
|
364
|
+
clearPending(run);
|
|
280
365
|
// Same reasoning as `poke`, and this one is the buyer pressing a button:
|
|
281
366
|
// "Verificar de novo" that cleared the panel and sent nothing — because
|
|
282
367
|
// an ask was still notionally in flight — is the exact complaint.
|
|
@@ -285,25 +370,29 @@ export function createPollLoop(
|
|
|
285
370
|
run.stopped = false;
|
|
286
371
|
run.errors = 0;
|
|
287
372
|
run.healthy = 0;
|
|
373
|
+
run.supersededAsks = 0;
|
|
288
374
|
run.startedAt = Date.now();
|
|
375
|
+
armDeadline(run, options, sink);
|
|
289
376
|
sink.setTimedOut(false);
|
|
290
377
|
sink.setError(null);
|
|
291
378
|
void tick();
|
|
292
379
|
},
|
|
293
380
|
poke: (): void => {
|
|
294
381
|
if (run.cancelled || run.stopped) return;
|
|
295
|
-
if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
|
|
296
382
|
// Deliberately NOT gated on `inFlight`: the shopper who just came back
|
|
297
383
|
// from their bank app is exactly the case where the previous ask is a
|
|
298
|
-
// socket that died while the screen was hidden. Abandon it and ask now
|
|
384
|
+
// socket that died while the screen was hidden. Abandon it and ask now —
|
|
385
|
+
// but `claimRearm` bounds how many live asks that may abandon in a row.
|
|
386
|
+
if (!claimRearm(run)) return;
|
|
299
387
|
run.attempt += 1;
|
|
300
388
|
run.inFlight = false;
|
|
301
|
-
clearPending();
|
|
389
|
+
clearPending(run);
|
|
302
390
|
void tick();
|
|
303
391
|
},
|
|
304
392
|
stop: (): void => {
|
|
305
393
|
run.cancelled = true;
|
|
306
|
-
clearPending();
|
|
394
|
+
clearPending(run);
|
|
395
|
+
clearDeadline(run);
|
|
307
396
|
},
|
|
308
397
|
};
|
|
309
398
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a re-arm may abandon an ask that is still in flight (FUT-1259).
|
|
3
|
+
*
|
|
4
|
+
* `visibilitychange` and `online` drive the loop's `poke`, and both fire for
|
|
5
|
+
* the same reason the wait exists: the shopper went to their bank app and came
|
|
6
|
+
* back, and the request left behind is quite likely a socket that died while
|
|
7
|
+
* the screen was hidden. Abandoning it is right. Abandoning it EVERY time is
|
|
8
|
+
* what this module bounds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The state a re-arm decision reads. Structural on purpose: the poll loop's own
|
|
13
|
+
* run object satisfies it, and typing it that way keeps this module free of an
|
|
14
|
+
* import back into the loop that imports it. Module-private — the loop passes
|
|
15
|
+
* its run and never names this type, so exporting it is an unused export.
|
|
16
|
+
*/
|
|
17
|
+
interface RearmState {
|
|
18
|
+
inFlight: boolean;
|
|
19
|
+
askedAt: number;
|
|
20
|
+
supersededAsks: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long a re-arm must stay quiet after the last ask.
|
|
25
|
+
*
|
|
26
|
+
* Returning to a tab commonly fires `visibilitychange` and `online` together —
|
|
27
|
+
* and a shopper flicking between the bank app and the store fires them again
|
|
28
|
+
* per trip. This collapses a burst into one request without delaying it: the
|
|
29
|
+
* first re-arm of a burst polls immediately, the rest fall inside the gap.
|
|
30
|
+
*/
|
|
31
|
+
const REARM_QUIET_MS = 1_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many live asks a re-arm may abandon before it has to let one finish.
|
|
35
|
+
*
|
|
36
|
+
* The quiet window alone looked sufficient and is not, because it is measured
|
|
37
|
+
* against the CURRENT ask rather than against the previous re-arm. Once a round
|
|
38
|
+
* trip runs longer than the window — which is the ordinary state of the flaky
|
|
39
|
+
* link this whole feature exists for — every re-arm supersedes an ask that was
|
|
40
|
+
* still alive. Nothing is ever absorbed, so `errors` never rises, so the 2.5s →
|
|
41
|
+
* 5s → 10s backoff never engages, and the wait sits near one request per window
|
|
42
|
+
* instead of decaying to one per ten.
|
|
43
|
+
*
|
|
44
|
+
* So the budget: after this many abandoned asks with no answer in between, a
|
|
45
|
+
* re-arm stands down and lets the in-flight ask either return or hit its own
|
|
46
|
+
* `askTimeoutMs`. Either outcome reaches `absorb`, which refills the budget, so
|
|
47
|
+
* this only bites while genuinely nothing is getting through.
|
|
48
|
+
*
|
|
49
|
+
* **What it does NOT bound: the re-arm rate itself.** A re-arm that finds the
|
|
50
|
+
* loop idle spends nothing, so on a link whose asks RESOLVE inside the window —
|
|
51
|
+
* a fast 500, or the immediate `TypeError: Load failed` iOS raises for a killed
|
|
52
|
+
* socket — six `online` events still cost six requests, exactly as before. That
|
|
53
|
+
* flavour is bounded by {@link REARM_QUIET_MS} alone and always was. This
|
|
54
|
+
* constant is about the ask that hangs, which is the one the quiet window
|
|
55
|
+
* could not see.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_SUPERSEDED_ASKS = 3;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a re-arm may proceed, and account for it if it may.
|
|
61
|
+
*
|
|
62
|
+
* Named `claim` rather than `may…` because it MUTATES on success: a re-arm that
|
|
63
|
+
* abandons a live ask spends one of the budget above. A re-arm that finds no
|
|
64
|
+
* ask in flight — the loop is simply between ticks — spends nothing, since
|
|
65
|
+
* there is no request to amplify.
|
|
66
|
+
*
|
|
67
|
+
* That second rule is what makes a denial safe, and it is an invariant worth
|
|
68
|
+
* stating: `supersededAsks > 0` implies `inFlight`, because every transition to
|
|
69
|
+
* `inFlight === false` in a live run either passes through `absorb` (which
|
|
70
|
+
* refills) or is followed synchronously by a `tick`. So a re-arm arriving at an
|
|
71
|
+
* IDLE loop is never denied, whatever the budget reads.
|
|
72
|
+
*
|
|
73
|
+
* The cost of a denial, stated honestly: the fourth consecutive return from the
|
|
74
|
+
* bank app does NOT poll immediately. It waits for the in-flight ask to resolve
|
|
75
|
+
* or reach `askTimeoutMs` (15s by default), plus one backoff step — so worst
|
|
76
|
+
* case the buyer learns they paid roughly 17s later than they would have. That
|
|
77
|
+
* is the price of not hammering `/status` on a link that is answering nothing,
|
|
78
|
+
* and the buyer's own "Verificar de novo" (`restart`) resets the budget outright.
|
|
79
|
+
*/
|
|
80
|
+
export function claimRearm(run: RearmState): boolean {
|
|
81
|
+
if (Date.now() - run.askedAt < REARM_QUIET_MS) return false;
|
|
82
|
+
if (!run.inFlight) return true;
|
|
83
|
+
if (run.supersededAsks >= MAX_SUPERSEDED_ASKS) return false;
|
|
84
|
+
run.supersededAsks += 1;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { useCheckoutClientApi } from "./client-context";
|
|
4
|
+
import { useCheckoutCopy } from "./copy-context";
|
|
4
5
|
import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
|
|
5
6
|
import type { OrderStatus } from "./types";
|
|
6
7
|
|
|
@@ -83,6 +84,11 @@ export function usePaymentPolling(
|
|
|
83
84
|
// belongs in the deps below rather than being read out of a ref: a checkout
|
|
84
85
|
// re-pointed at another mount must re-poll against THAT one.
|
|
85
86
|
const client = useCheckoutClientApi();
|
|
87
|
+
// The buyer's own sentence for "we could not reach the server", reused for an
|
|
88
|
+
// ask abandoned for hanging. `Result.error` is rendered verbatim by the PIX,
|
|
89
|
+
// card and wallet panels, so without this a hang printed the English token
|
|
90
|
+
// `timeout` into a Portuguese screen.
|
|
91
|
+
const transportCopy = useCheckoutCopy().screens.transport;
|
|
86
92
|
// The live wait, so the returned action stays stable across renders while
|
|
87
93
|
// still reaching whichever loop the effect currently owns.
|
|
88
94
|
const loop = useRef<PollLoop | null>(null);
|
|
@@ -92,7 +98,14 @@ export function usePaymentPolling(
|
|
|
92
98
|
|
|
93
99
|
const running = createPollLoop(
|
|
94
100
|
() => client.getStatus(orderId),
|
|
95
|
-
{
|
|
101
|
+
{
|
|
102
|
+
intervalMs,
|
|
103
|
+
maxWaitMs,
|
|
104
|
+
slowAfterPolls,
|
|
105
|
+
slowIntervalMs,
|
|
106
|
+
askTimeoutMs,
|
|
107
|
+
askTimeoutError: transportCopy.offline,
|
|
108
|
+
},
|
|
96
109
|
{ setStatus, setError, setTimedOut },
|
|
97
110
|
);
|
|
98
111
|
loop.current = running;
|
|
@@ -104,7 +117,17 @@ export function usePaymentPolling(
|
|
|
104
117
|
running.stop();
|
|
105
118
|
loop.current = null;
|
|
106
119
|
};
|
|
107
|
-
|
|
120
|
+
}, [
|
|
121
|
+
orderId,
|
|
122
|
+
intervalMs,
|
|
123
|
+
enabled,
|
|
124
|
+
maxWaitMs,
|
|
125
|
+
slowAfterPolls,
|
|
126
|
+
slowIntervalMs,
|
|
127
|
+
askTimeoutMs,
|
|
128
|
+
client,
|
|
129
|
+
transportCopy,
|
|
130
|
+
]);
|
|
108
131
|
|
|
109
132
|
const checkAgain = useCallback(() => {
|
|
110
133
|
loop.current?.restart();
|