@prosopo/procaptcha-frictionless 2.13.14 → 2.13.16

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.
@@ -35,6 +35,7 @@ import {
35
35
  type RetryCoords,
36
36
  consumeRetryMountProps,
37
37
  handleSessionInvalidated,
38
+ normaliseRetryCoords,
38
39
  } from "./sessionInvalidatedRecovery.js";
39
40
 
40
41
  // Each session uses exactly one solver — chosen by the /frictionless response.
@@ -105,6 +106,16 @@ export const ProcaptchaFrictionless = ({
105
106
  // After we've retried once, a second NO_SESSION_FOUND falls back to the
106
107
  // inner widget's own `frictionlessState.restart()` path.
107
108
  const sessionInvalidatedFiredRef = useRef(false);
109
+ // Bumped on every mount so the replacement widget gets a fresh React
110
+ // `key`. Without it a re-render for the same captcha type reconciles onto
111
+ // the existing element, and the inner widget keeps the manager it built on
112
+ // first mount — still closed over the sessionId we've just replaced.
113
+ const mountCountRef = useRef(0);
114
+ // Set when the next mount must open its challenge without waiting for a
115
+ // checkbox click. Held in a ref rather than passed down through `start()`
116
+ // because `providerRetry` re-invokes `start` with no arguments, which would
117
+ // otherwise drop the flag on the first provider retry.
118
+ const nextMountAutoStartRef = useRef(false);
108
119
 
109
120
  useEffect(() => {
110
121
  if (!config.language) return;
@@ -215,6 +226,19 @@ export const ProcaptchaFrictionless = ({
215
226
  void start();
216
227
  };
217
228
 
229
+ // The user pressed reload on the challenge. The provider consumed this
230
+ // session when it issued the challenge, so there is no way to ask it
231
+ // for another one — mint a new session by re-running frictionless and
232
+ // re-mount the widget with `autoStart`, which is what makes a new
233
+ // challenge appear instead of the modal simply closing. Not one-shot:
234
+ // the user may keep asking for a different challenge.
235
+ const onReload = (x?: number, y?: number) => {
236
+ pendingRetryCoordsRef.current = normaliseRetryCoords(x, y);
237
+ nextMountAutoStartRef.current = true;
238
+ resetState(0);
239
+ void start();
240
+ };
241
+
218
242
  // Consume any pending retry coords now — the resumed widget owns them
219
243
  // for exactly one auto-fired `manager.start(x, y)`. Cleared so a
220
244
  // subsequent escalation/re-render doesn't accidentally re-inject.
@@ -222,14 +246,22 @@ export const ProcaptchaFrictionless = ({
222
246
  // over pending retry coords when both are present, because escalation
223
247
  // is the current transition and the pending retry belongs to a prior
224
248
  // widget instance that never got to consume them.
249
+ const forcedAutoStart = nextMountAutoStartRef.current;
250
+ nextMountAutoStartRef.current = false;
225
251
  const { autoStart: resumedAutoStart, startCoords: retryStartCoords } =
226
- consumeRetryMountProps(pendingRetryCoordsRef, autoStart);
252
+ consumeRetryMountProps(
253
+ pendingRetryCoordsRef,
254
+ autoStart || forcedAutoStart,
255
+ );
227
256
  const startCoords = escalationCoords ?? retryStartCoords;
257
+ mountCountRef.current += 1;
258
+ const mountKey = mountCountRef.current;
228
259
 
229
260
  if (captchaType === CaptchaType.image) {
230
261
  const Procaptcha = await ProcaptchaLoader();
231
262
  setComponentToRender(
232
263
  <Procaptcha
264
+ key={mountKey}
233
265
  config={config}
234
266
  callbacks={callbacks}
235
267
  frictionlessState={frictionlessState}
@@ -237,12 +269,14 @@ export const ProcaptchaFrictionless = ({
237
269
  autoStart={resumedAutoStart}
238
270
  startCoords={startCoords}
239
271
  onSessionInvalidated={onSessionInvalidated}
272
+ onReload={onReload}
240
273
  />,
241
274
  );
242
275
  } else if (captchaType === CaptchaType.puzzle) {
243
276
  const ProcaptchaPuzzle = await ProcaptchaPuzzleLoader();
244
277
  setComponentToRender(
245
278
  <ProcaptchaPuzzle
279
+ key={mountKey}
246
280
  config={config}
247
281
  callbacks={callbacks}
248
282
  frictionlessState={frictionlessState}
@@ -256,6 +290,7 @@ export const ProcaptchaFrictionless = ({
256
290
  const ProcaptchaPow = await ProcaptchaPowLoader();
257
291
  setComponentToRender(
258
292
  <ProcaptchaPow
293
+ key={mountKey}
259
294
  config={config}
260
295
  callbacks={callbacks}
261
296
  frictionlessState={frictionlessState}
@@ -26,13 +26,10 @@ export type RetryCoords = { x: number; y: number };
26
26
  export type MutableRef<T> = { current: T };
27
27
 
28
28
  /**
29
- * Semantics of the outer recovery handler. Returns whether the caller
30
- * should proceed to re-run the frictionless flow (`start()`), and mutates
31
- * the passed refs to record the one-shot fire + pending coords.
29
+ * The checkbox click position a re-mounted widget should start from, or
30
+ * `null` when there isn't a real one to carry over.
32
31
  *
33
- * - Second calls are ignored (one-shot per outer widget lifetime) so a
34
- * persistently broken session doesn't loop.
35
- * - Coords are recorded only for a real trusted checkbox click. A partial
32
+ * - Coords are kept only for a real trusted checkbox click. A partial
36
33
  * pair (only x or only y numeric) is treated as "no coords" so we
37
34
  * never accidentally embed `NaN` into the solution salt.
38
35
  * - `(0, 0)` is treated as "no coords" too — that's what the widgets
@@ -42,6 +39,23 @@ export type MutableRef<T> = { current: T };
42
39
  * is identical; we discard the pair here so future readers can tell
43
40
  * the two apart.
44
41
  */
42
+ export const normaliseRetryCoords = (
43
+ x: number | undefined,
44
+ y: number | undefined,
45
+ ): RetryCoords | null => {
46
+ if (typeof x !== "number" || typeof y !== "number") return null;
47
+ if (x === 0 && y === 0) return null;
48
+ return { x, y };
49
+ };
50
+
51
+ /**
52
+ * Semantics of the outer recovery handler. Returns whether the caller
53
+ * should proceed to re-run the frictionless flow (`start()`), and mutates
54
+ * the passed refs to record the one-shot fire + pending coords.
55
+ *
56
+ * Second calls are ignored (one-shot per outer widget lifetime) so a
57
+ * persistently broken session doesn't loop.
58
+ */
45
59
  export const handleSessionInvalidated = (
46
60
  x: number | undefined,
47
61
  y: number | undefined,
@@ -50,9 +64,7 @@ export const handleSessionInvalidated = (
50
64
  ): { shouldRestart: boolean } => {
51
65
  if (firedRef.current) return { shouldRestart: false };
52
66
  firedRef.current = true;
53
- const bothNumeric = typeof x === "number" && typeof y === "number";
54
- const isRealClick = bothNumeric && (x !== 0 || y !== 0);
55
- pendingCoordsRef.current = isRealClick ? { x, y } : null;
67
+ pendingCoordsRef.current = normaliseRetryCoords(x, y);
56
68
  return { shouldRestart: true };
57
69
  };
58
70
 
@@ -18,6 +18,7 @@ import {
18
18
  type RetryCoords,
19
19
  consumeRetryMountProps,
20
20
  handleSessionInvalidated,
21
+ normaliseRetryCoords,
21
22
  } from "../sessionInvalidatedRecovery.js";
22
23
 
23
24
  const ref = <T>(initial: T): MutableRef<T> => ({ current: initial });
@@ -127,3 +128,26 @@ describe("consumeRetryMountProps", () => {
127
128
  expect(mount).toEqual({ autoStart: false, startCoords: undefined });
128
129
  });
129
130
  });
131
+
132
+ describe("normaliseRetryCoords", () => {
133
+ it("keeps a real click", () => {
134
+ expect(normaliseRetryCoords(120, 340)).toEqual({ x: 120, y: 340 });
135
+ });
136
+
137
+ it("keeps a click that sits on one axis", () => {
138
+ expect(normaliseRetryCoords(0, 340)).toEqual({ x: 0, y: 340 });
139
+ });
140
+
141
+ it("drops (0, 0) — the autoStart / untrusted-event default, not a click", () => {
142
+ expect(normaliseRetryCoords(0, 0)).toBeNull();
143
+ });
144
+
145
+ it("drops a half-pair rather than letting NaN reach the salt", () => {
146
+ expect(normaliseRetryCoords(120, undefined)).toBeNull();
147
+ expect(normaliseRetryCoords(undefined, 340)).toBeNull();
148
+ });
149
+
150
+ it("drops a missing pair", () => {
151
+ expect(normaliseRetryCoords(undefined, undefined)).toBeNull();
152
+ });
153
+ });