@12-apps/payments-frontend 3.21.1 → 3.21.3

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.1",
3
+ "version": "3.21.3",
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.",
@@ -0,0 +1,400 @@
1
+ import type { Result } from "../../result";
2
+ import { TERMINAL_STATUSES, type OrderStatus } from "./types";
3
+
4
+ /**
5
+ * ONE self-scheduling wait for a payment to settle, with no React in it.
6
+ *
7
+ * Split out of `use-payment-polling.ts` so the hook there is what it reads as:
8
+ * an effect that owns a loop for the length of an order id. Everything here is
9
+ * the loop itself — the backoff, the wall clock, the per-ask timeout and the
10
+ * attempt counter that lets a hung request be abandoned rather than waited on.
11
+ */
12
+
13
+ export interface PollingOptions {
14
+ /** Delay between successful polls (ms). */
15
+ intervalMs?: number;
16
+ /**
17
+ * How long one ask may hang before it is abandoned (ms). Defaults to
18
+ * {@link DEFAULT_ASK_TIMEOUT_MS}; consumers override it only in tests.
19
+ */
20
+ askTimeoutMs?: number;
21
+ /**
22
+ * What the BUYER reads when an ask is abandoned for hanging.
23
+ *
24
+ * The hook passes the transport's own `copy.offline`, so a dead socket reads
25
+ * as the dropped connection it is. Optional only so a direct caller of
26
+ * `createPollLoop` need not thread copy it does not have.
27
+ */
28
+ askTimeoutError?: string;
29
+ /** Poll only while `true` (e.g. after a card charge is submitted). */
30
+ enabled?: boolean;
31
+ /**
32
+ * WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
33
+ * `timedOut` once this many milliseconds have passed since the wait began.
34
+ * Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
35
+ * server-side and comes back as a terminal EXPIRED).
36
+ *
37
+ * It used to be a count of HEALTHY polls, which measured the wrong thing in
38
+ * the only case that matters. A wait that is failing makes no healthy polls,
39
+ * so the count stood still while the clock ran: the hosted-return leg spun
40
+ * "Confirmando seu pagamento…" forever on a connection that never came back,
41
+ * because the only counter that could have stopped it was the one the
42
+ * failure had frozen. Wall time cannot be frozen by the failure it measures.
43
+ */
44
+ maxWaitMs?: number;
45
+ /**
46
+ * Opt-in BACKOFF: after this many healthy polls, keep asking at
47
+ * {@link slowIntervalMs} instead of {@link intervalMs}.
48
+ *
49
+ * A single interval cannot serve a long wait, because the two things it
50
+ * decides pull opposite ways. It is how fast a buyer WHO PAID learns that
51
+ * they did — every poll is a provider round trip, and the answer lands within
52
+ * seconds of the webhook — and it is also what an abandoned checkout costs
53
+ * for the rest of the window. Tuning one picks the other's loser: a slow
54
+ * interval taxes the common case (the person on this screen almost always
55
+ * paid) to subsidise the rare one.
56
+ *
57
+ * Splitting them costs neither. Both must be set for backoff to apply.
58
+ */
59
+ slowAfterPolls?: number;
60
+ slowIntervalMs?: number;
61
+ }
62
+
63
+ /**
64
+ * The slowest a failing poll may go (FUT-1144).
65
+ *
66
+ * Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
67
+ * The cap is what keeps the recovery cheap: a shopper whose signal comes back
68
+ * during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
69
+ * the two re-arm events below usually beat it outright.
70
+ */
71
+ const MAX_ERROR_BACKOFF_MS = 10_000;
72
+
73
+ /**
74
+ * How long a re-arm must stay quiet after the last ask.
75
+ *
76
+ * Returning to a tab commonly fires `visibilitychange` and `online` together —
77
+ * and a shopper flicking between the bank app and the store fires them again
78
+ * per trip. This collapses a burst into one request without delaying it: the
79
+ * first re-arm of a burst polls immediately, the rest fall inside the gap.
80
+ */
81
+ const REARM_QUIET_MS = 1_000;
82
+
83
+ /**
84
+ * How long ONE status ask may take before the loop stops waiting on it.
85
+ *
86
+ * Comfortably longer than any healthy round trip on a bad link, so an ordinary
87
+ * slow poll is never mistaken for a dead one; short enough that a socket that
88
+ * died silently — the iOS case this whole feature exists for — costs one
89
+ * backoff step rather than the rest of the wait.
90
+ */
91
+ const DEFAULT_ASK_TIMEOUT_MS = 15_000;
92
+
93
+ /**
94
+ * Last resort only, for a host that wired the loop without `askTimeoutError`.
95
+ * `Result.error` is what the BUYER reads — the PIX, card and wallet panels all
96
+ * render it verbatim — so the hook passes the transport's own `copy.offline`
97
+ * down and a hang reads as the dropped connection it is.
98
+ */
99
+ const ASK_TIMED_OUT = "timeout";
100
+
101
+ /**
102
+ * How long before the next ask, given how many healthy polls have happened.
103
+ *
104
+ * A pure function of the options, so it lives out here rather than inside the
105
+ * effect — the hook is at its size gate, and a scheduling RULE is easier to
106
+ * read (and to test) stated once than threaded through a closure.
107
+ *
108
+ * Reads the count AFTER the poll just made, so the slow phase begins on the
109
+ * poll FOLLOWING the threshold rather than one early: a wait described as "N
110
+ * fast polls" has to actually make N of them.
111
+ */
112
+ function healthyDelay(healthy: number, options: PollingOptions): number {
113
+ const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
114
+ const backingOff =
115
+ slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
116
+ return backingOff ? slowIntervalMs : intervalMs;
117
+ }
118
+
119
+ /**
120
+ * The same rule with consecutive FAILURES folded in (FUT-1144).
121
+ *
122
+ * Errors never stop the wait, they only slow it. Doubling from the healthy
123
+ * cadence is what makes a ten-second blip cost one extra beat instead of the
124
+ * whole confirmation: four failures used to be terminal, and four failures is
125
+ * what a Wi-Fi→4G handoff produces at the default 2.5 s.
126
+ *
127
+ * The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
128
+ * larger — a failing poll must never ask FASTER than a succeeding one, which
129
+ * is what a bare cap would do to a consumer whose slow interval is longer.
130
+ */
131
+ function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
132
+ const base = healthyDelay(healthy, options);
133
+ if (errors === 0) return base;
134
+ return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
135
+ }
136
+
137
+ /** Where a running wait writes what it has learned. */
138
+ interface PollSink {
139
+ setStatus: (status: OrderStatus) => void;
140
+ setError: (error: string | null) => void;
141
+ setTimedOut: (timedOut: boolean) => void;
142
+ }
143
+
144
+ /**
145
+ * The mutable bookkeeping one wait carries.
146
+ *
147
+ * `settled` and `stopped` are deliberately NOT the same flag. Settled means the
148
+ * order reached a terminal status and there is nothing left to ask about, ever.
149
+ * Stopped only means nothing is scheduled — which the wall clock running out
150
+ * also produces, and which the buyer's own "check again" is allowed to undo.
151
+ */
152
+ function newRun() {
153
+ return {
154
+ cancelled: false,
155
+ settled: false,
156
+ stopped: false,
157
+ inFlight: false,
158
+ /**
159
+ * Which ask is the CURRENT one.
160
+ *
161
+ * A hung request cannot be un-sent, so it is abandoned instead: every ask
162
+ * carries the attempt it belongs to, and a late answer from a superseded
163
+ * attempt is dropped. Without this, `inFlight` stayed true for as long as
164
+ * the socket did — and `inFlight` gates the tick, the re-arm AND the
165
+ * buyer's own "check again", so one dead request disabled every mechanism
166
+ * this loop has for recovering from a dead request.
167
+ */
168
+ attempt: 0,
169
+ errors: 0,
170
+ healthy: 0,
171
+ startedAt: 0,
172
+ askedAt: 0,
173
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
174
+ /**
175
+ * The wall clock, as a timer rather than a check after an ask. `outOfTime`
176
+ * needs an ask to RETURN, and `askTimeout` stands down when its attempt is
177
+ * superseded — which every `poke` does. A shopper flicking to their bank
178
+ * app faster than `askTimeoutMs` refreshed it forever, so `maxWaitMs` never
179
+ * fired and the hosted return span with no error and no check-again button.
180
+ */
181
+ deadline: undefined as ReturnType<typeof setTimeout> | undefined,
182
+ };
183
+ }
184
+
185
+ /** The mutable state of one wait — see {@link newRun}. */
186
+ type PollRun = ReturnType<typeof newRun>;
187
+
188
+ /**
189
+ * Give up WAITING on one ask — never on the wait itself.
190
+ *
191
+ * Resolves as an ordinary failed poll, so a hang costs exactly what a 500
192
+ * costs: the error counter rises, the backoff widens, and the next tick is
193
+ * scheduled. `run.attempt` is what makes it safe — if the real answer lands
194
+ * afterwards it belongs to a superseded attempt and is dropped.
195
+ */
196
+ function askTimeout(
197
+ run: PollRun,
198
+ mine: number,
199
+ options: PollingOptions,
200
+ ): Promise<Result<OrderStatus>> {
201
+ return new Promise((resolve) => {
202
+ setTimeout(() => {
203
+ if (run.attempt === mine) {
204
+ resolve({ ok: false, error: options.askTimeoutError ?? ASK_TIMED_OUT });
205
+ }
206
+ }, options.askTimeoutMs ?? DEFAULT_ASK_TIMEOUT_MS);
207
+ });
208
+ }
209
+
210
+ /**
211
+ * Fold one poll's answer into the run and into what the screen says.
212
+ *
213
+ * Returns whether the wait continues. A TERMINAL status is the only answer
214
+ * that can end it from here — the wall clock is the caller's business, because
215
+ * it has to be consulted against the delay it is about to sleep for.
216
+ */
217
+ function absorb(run: PollRun, result: Result<OrderStatus>, sink: PollSink): boolean {
218
+ if (!result.ok) {
219
+ run.errors += 1;
220
+ sink.setError(result.error);
221
+ return true;
222
+ }
223
+ run.errors = 0;
224
+ sink.setError(null);
225
+ sink.setStatus(result.data);
226
+ if (TERMINAL_STATUSES.includes(result.data)) {
227
+ run.settled = true;
228
+ run.stopped = true;
229
+ return false;
230
+ }
231
+ run.healthy += 1;
232
+ return true;
233
+ }
234
+
235
+ /** Cancel whatever tick is scheduled, if any. */
236
+ function clearPending(run: PollRun): void {
237
+ if (run.timer !== undefined) clearTimeout(run.timer);
238
+ run.timer = undefined;
239
+ }
240
+
241
+ /** Cancel the wall clock, if it is armed. */
242
+ function clearDeadline(run: PollRun): void {
243
+ if (run.deadline !== undefined) clearTimeout(run.deadline);
244
+ run.deadline = undefined;
245
+ }
246
+
247
+ /**
248
+ * The wall clock, armed once per run by `restart`, so `maxWaitMs` holds whether
249
+ * or not an ask ever returns. `outOfTime` stays too: it ends the wait one delay
250
+ * EARLIER when asks ARE returning. This is the backstop for when they are not.
251
+ */
252
+ function armDeadline(run: PollRun, options: PollingOptions, sink: PollSink): void {
253
+ clearDeadline(run);
254
+ if (options.maxWaitMs === undefined) return;
255
+ run.deadline = setTimeout(() => {
256
+ run.deadline = undefined;
257
+ if (run.cancelled || run.settled) return;
258
+ run.stopped = true;
259
+ clearPending(run);
260
+ sink.setTimedOut(true);
261
+ }, options.maxWaitMs);
262
+ }
263
+
264
+ /**
265
+ * One ask, bounded by {@link askTimeout} and guaranteed not to throw. `race`
266
+ * rejects the instant either input does, so an `ask` that throws — a host
267
+ * client wrapping ours — left `inFlight` true with nothing scheduled: the very
268
+ * wedge this file removes, through the one door the race does not close.
269
+ */
270
+ async function askOnce(
271
+ ask: () => Promise<Result<OrderStatus>>,
272
+ run: PollRun,
273
+ mine: number,
274
+ options: PollingOptions,
275
+ ): Promise<Result<OrderStatus>> {
276
+ try {
277
+ return await Promise.race([ask(), askTimeout(run, mine, options)]);
278
+ } catch (error) {
279
+ const fallback = options.askTimeoutError ?? ASK_TIMED_OUT;
280
+ return { ok: false, error: error instanceof Error ? error.message : fallback };
281
+ }
282
+ }
283
+
284
+ /** Whether sleeping `delay` would carry the wait past its wall-clock bound. */
285
+ function outOfTime(run: PollRun, options: PollingOptions, delay: number): boolean {
286
+ return options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
287
+ }
288
+
289
+ /**
290
+ * Whether this attempt's answer may still be written. A superseded one writes
291
+ * nothing — it may be a hung request finally answering — EXCEPT a terminal
292
+ * status, which is the answer the wait exists for, is idempotent, and would
293
+ * otherwise be dropped because a re-arm landed first.
294
+ */
295
+ function mayWrite(run: PollRun, mine: number, result: Result<OrderStatus>): boolean {
296
+ return run.attempt === mine || (result.ok && TERMINAL_STATUSES.includes(result.data));
297
+ }
298
+
299
+ /** Book the next tick, or end the wait because its clock has run out. */
300
+ function scheduleNext(
301
+ run: PollRun,
302
+ options: PollingOptions,
303
+ sink: PollSink,
304
+ again: () => void,
305
+ ): void {
306
+ const delay = pollDelay(run.healthy, run.errors, options);
307
+ if (outOfTime(run, options, delay)) {
308
+ run.stopped = true;
309
+ clearDeadline(run);
310
+ sink.setTimedOut(true);
311
+ return;
312
+ }
313
+ clearPending(run);
314
+ run.timer = setTimeout(again, delay);
315
+ }
316
+
317
+ /** The handle the hook holds on one running wait. */
318
+ export interface PollLoop {
319
+ /** Reset the clock and the counters, then ask immediately. */
320
+ restart: () => void;
321
+ /** Ask immediately, keeping the clock — the re-arm events' entry point. */
322
+ poke: () => void;
323
+ /** Tear down: nothing further is scheduled and nothing further is written. */
324
+ stop: () => void;
325
+ }
326
+
327
+ /**
328
+ * One self-scheduling wait, with no React in it.
329
+ *
330
+ * A `setTimeout` chain rather than an interval, so requests never overlap; the
331
+ * mutable counters live in one object because the flakiness gate's
332
+ * `no-global-state-mutation` is about exactly this shape, and because the
333
+ * whole loop has to be cancellable from a React cleanup.
334
+ */
335
+ export function createPollLoop(
336
+ ask: () => Promise<Result<OrderStatus>>,
337
+ options: PollingOptions,
338
+ sink: PollSink,
339
+ ): PollLoop {
340
+ const run = newRun();
341
+
342
+
343
+ const tick = async (): Promise<void> => {
344
+ if (run.cancelled || run.settled || run.inFlight) return;
345
+ run.inFlight = true;
346
+ run.askedAt = Date.now();
347
+ const mine = ++run.attempt;
348
+ // Bounded, because the wall clock below is only consulted once an ask
349
+ // RETURNS: a request that never returns never reaches it, so the wait that
350
+ // is supposed to end after `maxWaitMs` runs forever showing "we are still
351
+ // trying". Racing a timer turns a hang into an ordinary failed poll, which
352
+ // the backoff and the re-arm already know how to handle.
353
+ const result = await askOnce(ask, run, mine, options);
354
+ if (!mayWrite(run, mine, result)) return;
355
+ if (run.attempt === mine) run.inFlight = false;
356
+ if (run.cancelled || run.settled) return;
357
+ if (!absorb(run, result, sink)) {
358
+ clearDeadline(run);
359
+ return;
360
+ }
361
+ if (run.attempt !== mine) return;
362
+ scheduleNext(run, options, sink, () => void tick());
363
+ };
364
+
365
+ return {
366
+ restart: (): void => {
367
+ if (run.cancelled || run.settled) return;
368
+ clearPending(run);
369
+ // Same reasoning as `poke`, and this one is the buyer pressing a button:
370
+ // "Verificar de novo" that cleared the panel and sent nothing — because
371
+ // an ask was still notionally in flight — is the exact complaint.
372
+ run.attempt += 1;
373
+ run.inFlight = false;
374
+ run.stopped = false;
375
+ run.errors = 0;
376
+ run.healthy = 0;
377
+ run.startedAt = Date.now();
378
+ armDeadline(run, options, sink);
379
+ sink.setTimedOut(false);
380
+ sink.setError(null);
381
+ void tick();
382
+ },
383
+ poke: (): void => {
384
+ if (run.cancelled || run.stopped) return;
385
+ if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
386
+ // Deliberately NOT gated on `inFlight`: the shopper who just came back
387
+ // from their bank app is exactly the case where the previous ask is a
388
+ // socket that died while the screen was hidden. Abandon it and ask now.
389
+ run.attempt += 1;
390
+ run.inFlight = false;
391
+ clearPending(run);
392
+ void tick();
393
+ },
394
+ stop: (): void => {
395
+ run.cancelled = true;
396
+ clearPending(run);
397
+ clearDeadline(run);
398
+ },
399
+ };
400
+ }
@@ -1,45 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  import { useCheckoutClientApi } from "./client-context";
4
- import type { Result } from "../../result";
5
- import { TERMINAL_STATUSES, type OrderStatus } from "./types";
6
-
7
- interface PollingOptions {
8
- /** Delay between successful polls (ms). */
9
- intervalMs?: number;
10
- /** Poll only while `true` (e.g. after a card charge is submitted). */
11
- enabled?: boolean;
12
- /**
13
- * WALL-CLOCK bound on the whole wait (FUT-1144): stop scheduling and report
14
- * `timedOut` once this many milliseconds have passed since the wait began.
15
- * Undefined ⇒ unbounded (the PIX consumer passes none — its charge expires
16
- * server-side and comes back as a terminal EXPIRED).
17
- *
18
- * It used to be a count of HEALTHY polls, which measured the wrong thing in
19
- * the only case that matters. A wait that is failing makes no healthy polls,
20
- * so the count stood still while the clock ran: the hosted-return leg spun
21
- * "Confirmando seu pagamento…" forever on a connection that never came back,
22
- * because the only counter that could have stopped it was the one the
23
- * failure had frozen. Wall time cannot be frozen by the failure it measures.
24
- */
25
- maxWaitMs?: number;
26
- /**
27
- * Opt-in BACKOFF: after this many healthy polls, keep asking at
28
- * {@link slowIntervalMs} instead of {@link intervalMs}.
29
- *
30
- * A single interval cannot serve a long wait, because the two things it
31
- * decides pull opposite ways. It is how fast a buyer WHO PAID learns that
32
- * they did — every poll is a provider round trip, and the answer lands within
33
- * seconds of the webhook — and it is also what an abandoned checkout costs
34
- * for the rest of the window. Tuning one picks the other's loser: a slow
35
- * interval taxes the common case (the person on this screen almost always
36
- * paid) to subsidise the rare one.
37
- *
38
- * Splitting them costs neither. Both must be set for backoff to apply.
39
- */
40
- slowAfterPolls?: number;
41
- slowIntervalMs?: number;
42
- }
4
+ import { useCheckoutCopy } from "./copy-context";
5
+ import { createPollLoop, type PollLoop, type PollingOptions } from "./poll-loop";
6
+ import type { OrderStatus } from "./types";
43
7
 
44
8
  /** What a consumer reads off the wait, and the one action it can take. */
45
9
  interface PaymentPollingState {
@@ -60,179 +24,6 @@ interface PaymentPollingState {
60
24
  checkAgain: () => void;
61
25
  }
62
26
 
63
- /**
64
- * The slowest a failing poll may go (FUT-1144).
65
- *
66
- * Consecutive failures double the delay — 2.5 s, 5 s, 10 s — and stop there.
67
- * The cap is what keeps the recovery cheap: a shopper whose signal comes back
68
- * during a Wi-Fi→4G handoff waits at most this long to be told they paid, and
69
- * the two re-arm events below usually beat it outright.
70
- */
71
- const MAX_ERROR_BACKOFF_MS = 10_000;
72
-
73
- /**
74
- * How long a re-arm must stay quiet after the last ask.
75
- *
76
- * Returning to a tab commonly fires `visibilitychange` and `online` together —
77
- * and a shopper flicking between the bank app and the store fires them again
78
- * per trip. This collapses a burst into one request without delaying it: the
79
- * first re-arm of a burst polls immediately, the rest fall inside the gap.
80
- */
81
- const REARM_QUIET_MS = 1_000;
82
-
83
- /**
84
- * How long before the next ask, given how many healthy polls have happened.
85
- *
86
- * A pure function of the options, so it lives out here rather than inside the
87
- * effect — the hook is at its size gate, and a scheduling RULE is easier to
88
- * read (and to test) stated once than threaded through a closure.
89
- *
90
- * Reads the count AFTER the poll just made, so the slow phase begins on the
91
- * poll FOLLOWING the threshold rather than one early: a wait described as "N
92
- * fast polls" has to actually make N of them.
93
- */
94
- function healthyDelay(healthy: number, options: PollingOptions): number {
95
- const { intervalMs = 2500, slowAfterPolls, slowIntervalMs } = options;
96
- const backingOff =
97
- slowAfterPolls !== undefined && slowIntervalMs !== undefined && healthy >= slowAfterPolls;
98
- return backingOff ? slowIntervalMs : intervalMs;
99
- }
100
-
101
- /**
102
- * The same rule with consecutive FAILURES folded in (FUT-1144).
103
- *
104
- * Errors never stop the wait, they only slow it. Doubling from the healthy
105
- * cadence is what makes a ten-second blip cost one extra beat instead of the
106
- * whole confirmation: four failures used to be terminal, and four failures is
107
- * what a Wi-Fi→4G handoff produces at the default 2.5 s.
108
- *
109
- * The cap is `MAX_ERROR_BACKOFF_MS` or the healthy cadence, whichever is
110
- * larger — a failing poll must never ask FASTER than a succeeding one, which
111
- * is what a bare cap would do to a consumer whose slow interval is longer.
112
- */
113
- function pollDelay(healthy: number, errors: number, options: PollingOptions): number {
114
- const base = healthyDelay(healthy, options);
115
- if (errors === 0) return base;
116
- return Math.min(base * 2 ** (errors - 1), Math.max(base, MAX_ERROR_BACKOFF_MS));
117
- }
118
-
119
- /** Where a running wait writes what it has learned. */
120
- interface PollSink {
121
- setStatus: (status: OrderStatus) => void;
122
- setError: (error: string | null) => void;
123
- setTimedOut: (timedOut: boolean) => void;
124
- }
125
-
126
- /**
127
- * The mutable bookkeeping one wait carries.
128
- *
129
- * `settled` and `stopped` are deliberately NOT the same flag. Settled means the
130
- * order reached a terminal status and there is nothing left to ask about, ever.
131
- * Stopped only means nothing is scheduled — which the wall clock running out
132
- * also produces, and which the buyer's own "check again" is allowed to undo.
133
- */
134
- function newRun() {
135
- return {
136
- cancelled: false,
137
- settled: false,
138
- stopped: false,
139
- inFlight: false,
140
- errors: 0,
141
- healthy: 0,
142
- startedAt: 0,
143
- askedAt: 0,
144
- timer: undefined as ReturnType<typeof setTimeout> | undefined,
145
- };
146
- }
147
-
148
- /** The handle the hook holds on one running wait. */
149
- interface PollLoop {
150
- /** Reset the clock and the counters, then ask immediately. */
151
- restart: () => void;
152
- /** Ask immediately, keeping the clock — the re-arm events' entry point. */
153
- poke: () => void;
154
- /** Tear down: nothing further is scheduled and nothing further is written. */
155
- stop: () => void;
156
- }
157
-
158
- /**
159
- * One self-scheduling wait, with no React in it.
160
- *
161
- * A `setTimeout` chain rather than an interval, so requests never overlap; the
162
- * mutable counters live in one object because the flakiness gate's
163
- * `no-global-state-mutation` is about exactly this shape, and because the
164
- * whole loop has to be cancellable from a React cleanup.
165
- */
166
- function createPollLoop(
167
- ask: () => Promise<Result<OrderStatus>>,
168
- options: PollingOptions,
169
- sink: PollSink,
170
- ): PollLoop {
171
- const run = newRun();
172
-
173
- const clearPending = (): void => {
174
- if (run.timer !== undefined) clearTimeout(run.timer);
175
- run.timer = undefined;
176
- };
177
-
178
- const outOfTime = (delay: number): boolean =>
179
- options.maxWaitMs !== undefined && Date.now() - run.startedAt + delay >= options.maxWaitMs;
180
-
181
- const tick = async (): Promise<void> => {
182
- if (run.cancelled || run.settled || run.inFlight) return;
183
- run.inFlight = true;
184
- run.askedAt = Date.now();
185
- const result = await ask();
186
- run.inFlight = false;
187
- if (run.cancelled || run.settled) return;
188
- if (result.ok) {
189
- run.errors = 0;
190
- sink.setError(null);
191
- sink.setStatus(result.data);
192
- if (TERMINAL_STATUSES.includes(result.data)) {
193
- run.settled = true;
194
- run.stopped = true;
195
- return;
196
- }
197
- run.healthy += 1;
198
- } else {
199
- run.errors += 1;
200
- sink.setError(result.error);
201
- }
202
- const delay = pollDelay(run.healthy, run.errors, options);
203
- if (outOfTime(delay)) {
204
- run.stopped = true;
205
- sink.setTimedOut(true);
206
- return;
207
- }
208
- clearPending();
209
- run.timer = setTimeout(() => void tick(), delay);
210
- };
211
-
212
- return {
213
- restart: (): void => {
214
- if (run.cancelled || run.settled) return;
215
- clearPending();
216
- run.stopped = false;
217
- run.errors = 0;
218
- run.healthy = 0;
219
- run.startedAt = Date.now();
220
- sink.setTimedOut(false);
221
- sink.setError(null);
222
- void tick();
223
- },
224
- poke: (): void => {
225
- if (run.cancelled || run.stopped || run.inFlight) return;
226
- if (Date.now() - run.askedAt < REARM_QUIET_MS) return;
227
- clearPending();
228
- void tick();
229
- },
230
- stop: (): void => {
231
- run.cancelled = true;
232
- clearPending();
233
- },
234
- };
235
- }
236
27
 
237
28
  /**
238
29
  * Ask again the moment the shopper could plausibly have an answer for us.
@@ -283,6 +74,7 @@ export function usePaymentPolling(
283
74
  maxWaitMs,
284
75
  slowAfterPolls,
285
76
  slowIntervalMs,
77
+ askTimeoutMs,
286
78
  }: PollingOptions = {},
287
79
  ): PaymentPollingState {
288
80
  const [status, setStatus] = useState<OrderStatus | null>(null);
@@ -292,6 +84,11 @@ export function usePaymentPolling(
292
84
  // belongs in the deps below rather than being read out of a ref: a checkout
293
85
  // re-pointed at another mount must re-poll against THAT one.
294
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;
295
92
  // The live wait, so the returned action stays stable across renders while
296
93
  // still reaching whichever loop the effect currently owns.
297
94
  const loop = useRef<PollLoop | null>(null);
@@ -301,7 +98,14 @@ export function usePaymentPolling(
301
98
 
302
99
  const running = createPollLoop(
303
100
  () => client.getStatus(orderId),
304
- { intervalMs, maxWaitMs, slowAfterPolls, slowIntervalMs },
101
+ {
102
+ intervalMs,
103
+ maxWaitMs,
104
+ slowAfterPolls,
105
+ slowIntervalMs,
106
+ askTimeoutMs,
107
+ askTimeoutError: transportCopy.offline,
108
+ },
305
109
  { setStatus, setError, setTimedOut },
306
110
  );
307
111
  loop.current = running;
@@ -313,7 +117,17 @@ export function usePaymentPolling(
313
117
  running.stop();
314
118
  loop.current = null;
315
119
  };
316
- }, [orderId, intervalMs, enabled, maxWaitMs, slowAfterPolls, slowIntervalMs, client]);
120
+ }, [
121
+ orderId,
122
+ intervalMs,
123
+ enabled,
124
+ maxWaitMs,
125
+ slowAfterPolls,
126
+ slowIntervalMs,
127
+ askTimeoutMs,
128
+ client,
129
+ transportCopy,
130
+ ]);
317
131
 
318
132
  const checkAgain = useCallback(() => {
319
133
  loop.current?.restart();
@@ -321,3 +135,4 @@ export function usePaymentPolling(
321
135
 
322
136
  return { status, error, timedOut, checkAgain };
323
137
  }
138
+