@verbb/formie-browser 1.0.15 → 1.0.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.
Files changed (135) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/chunks/{address-country-BcEQKw0T.js → address-country-DLJF-OkC.js} +4 -8
  3. package/dist/chunks/{address-finder-BWGINCSA.js → address-finder-QK1m1eyv.js} +1 -1
  4. package/dist/chunks/{address-state-DI3ndhMU.js → address-state-CHKit8AW.js} +26 -35
  5. package/dist/chunks/{api-BK9b340y.js → api-BPtH5RBA.js} +152 -120
  6. package/dist/chunks/{api-Dxl_CKw-.js → api-BR2Gqy37.js} +306 -301
  7. package/dist/chunks/{api-ghNr2uxX.js → api-CmuOZ0i3.js} +1 -1
  8. package/dist/chunks/{bpoint-BwtuyN_O.js → bpoint-GvYfyPjD.js} +1 -1
  9. package/dist/chunks/{calculations-YEoVCG2q.js → calculations-DeEmdUDq.js} +5 -7
  10. package/dist/chunks/{captcha-eu-DWLoQFKB.js → captcha-eu-eU_M6snp.js} +1 -1
  11. package/dist/chunks/{checkbox-radio-gsm8QPJg.js → checkbox-radio-CSXo6svm.js} +4 -12
  12. package/dist/chunks/{combobox-Ba08_cHL.js → combobox-CJh2DoSO.js} +292 -333
  13. package/dist/chunks/{conditions-Ceeg6ReZ.js → conditions-Dz3068K0.js} +56 -72
  14. package/dist/chunks/{country-from-ip-DDj_mFki.js → country-from-ip-BqQ-BqLH.js} +1 -1
  15. package/dist/chunks/{custom-google-maps-C4l2S3GO.js → custom-google-maps-rNm5VFbb.js} +2 -6
  16. package/dist/chunks/{custom-link-CLazgKYH.js → custom-link-CLqFCssQ.js} +1 -1
  17. package/dist/chunks/{custom-maps-DqcAlD0v.js → custom-maps-DgjYQDLj.js} +4 -8
  18. package/dist/chunks/{date-picker-DaPvMrop.js → date-picker-BVJxJVYT.js} +30 -47
  19. package/dist/chunks/{debug-BV0DvdHx.js → debug-rhzqP1j2.js} +2 -14
  20. package/dist/chunks/{dist-ZLeW0zZ3.js → dist-CJReDlDK.js} +13 -8
  21. package/dist/chunks/{eway-uw0DzU-L.js → eway-BZuTBAUW.js} +1 -1
  22. package/dist/chunks/{field-references.resolver-Ca8MguFv.js → field-references.resolver-uSHf5-NP.js} +1 -1
  23. package/dist/chunks/{file-upload-B8_mH012.js → file-upload-vEUr_EuP.js} +114 -108
  24. package/dist/chunks/{friendly-captcha-v1-CVXaXgDO.js → friendly-captcha-v1-C_cF1WD2.js} +2 -2
  25. package/dist/chunks/{friendly-captcha-v2-DkeIVTKI.js → friendly-captcha-v2-BenESH-n.js} +2 -2
  26. package/dist/chunks/{friendly-challenge-DPG9MJb1.js → friendly-challenge-BgPBEMXx.js} +21 -35
  27. package/dist/chunks/{go-cardless-c4wLJdOt.js → go-cardless-DWZeB0Bq.js} +2 -6
  28. package/dist/chunks/{google-address-JHYvjwgN.js → google-address-DQR6rsbh.js} +2 -2
  29. package/dist/chunks/{hcaptcha-Dt3ELLlP.js → hcaptcha-COilLmRT.js} +1 -1
  30. package/dist/chunks/{hidden-BZhXnNPe.js → hidden-Bc2TpcgF.js} +1 -1
  31. package/dist/chunks/{http-C2QdHuWO.js → http-Dj_skqaa.js} +1 -1
  32. package/dist/chunks/{leaflet-src-DE6rv6M-.js → leaflet-src-Cg-LUOD8.js} +74 -122
  33. package/dist/chunks/{loqate-m51iPyO1.js → loqate-ZZClcacr.js} +1 -1
  34. package/dist/chunks/{mollie-Bn0PX173.js → mollie-8OMXIYa8.js} +2 -6
  35. package/dist/chunks/{moneris-BRbUvI4t.js → moneris-qYHJ9EFy.js} +1 -1
  36. package/dist/chunks/opayo-DlWPEnla.js +159 -0
  37. package/dist/chunks/{paddle-CympHVvd.js → paddle-CArfWih0.js} +1 -1
  38. package/dist/chunks/{password-validation-C8_uO_c_.js → password-validation-HGOU9kf3.js} +2 -2
  39. package/dist/chunks/{paypal-h75h6wae.js → paypal-BcaMCMGY.js} +1 -1
  40. package/dist/chunks/{payway-C9rHTCC_.js → payway-vxC7_bfR.js} +3 -11
  41. package/dist/chunks/{phone-country-B8fv-L5_.js → phone-country-Bx9EG50h.js} +123 -140
  42. package/dist/chunks/{place-kit-BnZGQ-L1.js → place-kit-BKk8xDXf.js} +2 -2
  43. package/dist/chunks/{placekit-autocomplete.esm-CZZERYur.js → placekit-autocomplete.esm-D2A_0iVU.js} +2 -5
  44. package/dist/chunks/{recaptcha-enterprise-CUs8BO6V.js → recaptcha-enterprise-Cx6HUpMa.js} +6 -12
  45. package/dist/chunks/recaptcha-shared-CcMI-r8t.js +33 -0
  46. package/dist/chunks/{recaptcha-v2-checkbox-P1McW4Pv.js → recaptcha-v2-checkbox-DQ9BJzcv.js} +2 -2
  47. package/dist/chunks/{recaptcha-v2-invisible-FGDqozSP.js → recaptcha-v2-invisible-BP0T8Pan.js} +2 -2
  48. package/dist/chunks/recaptcha-v3-DKMGQ27K.js +38 -0
  49. package/dist/chunks/{repeater-CyhDbozO.js → repeater-BR0RUvTs.js} +3 -7
  50. package/dist/chunks/{rich-text-uV4o_pGk.js → rich-text-H2A6cm1t.js} +33 -11
  51. package/dist/chunks/{rolldown-runtime-DtPi1Y-2.js → rolldown-runtime-VHNaYcLd.js} +1 -1
  52. package/dist/chunks/{sdk-lkwSvlpM.js → sdk-_cmrRYgp.js} +4 -16
  53. package/dist/chunks/{shared-OKhSay59.js → shared-CJ1LIEh3.js} +1 -3
  54. package/dist/chunks/{signature-7cldFbqj.js → signature-CfKA_poI.js} +9 -41
  55. package/dist/chunks/{snaptcha-C3QLYxFT.js → snaptcha-P3EoSQud.js} +1 -1
  56. package/dist/chunks/{square-C1z5DPVw.js → square-Dyz0p_GU.js} +1 -1
  57. package/dist/chunks/{stripe-Dmkdkq1y.js → stripe-Bfd_jp_k.js} +3 -6
  58. package/dist/chunks/{summary-Bn-g5SDx.js → summary-B3CAebLV.js} +44 -36
  59. package/dist/chunks/{survey-likert-fk1hUPff.js → survey-likert-CEtUceag.js} +1 -1
  60. package/dist/chunks/{survey-rank-BsLGl2LX.js → survey-rank-Dtt97y6s.js} +3 -7
  61. package/dist/chunks/{survey-rating-DVS6e6fM.js → survey-rating-mlDMI0uq.js} +9 -23
  62. package/dist/chunks/{table-j7MlLLCc.js → table-0k6Md0IO.js} +1 -1
  63. package/dist/chunks/{text-limit-LGMPhFuC.js → text-limit-CTVML6s7.js} +10 -10
  64. package/dist/chunks/{turnstile-ptihQfLl.js → turnstile-Sp4ubowK.js} +1 -1
  65. package/dist/chunks/{upload-manager-BW7l-BhF.js → upload-manager-Ccg3d_4I.js} +11 -25
  66. package/dist/index.d.ts +1 -0
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +752 -756
  69. package/dist/js/core/create-formie-client.d.ts.map +1 -1
  70. package/dist/js/core/dom-events.d.ts +5 -0
  71. package/dist/js/core/dom-events.d.ts.map +1 -1
  72. package/dist/js/core/page-client-event.d.ts.map +1 -1
  73. package/dist/js/core/submit-result-state.d.ts.map +1 -1
  74. package/dist/js/core/submit-result-ui.d.ts.map +1 -1
  75. package/dist/js/core/validation-focus.d.ts.map +1 -1
  76. package/dist/js/modules/captchas/factories.d.ts.map +1 -1
  77. package/dist/js/modules/captchas/host.d.ts +4 -1
  78. package/dist/js/modules/captchas/host.d.ts.map +1 -1
  79. package/dist/js/modules/captchas/recaptcha-shared.d.ts +1 -1
  80. package/dist/js/modules/captchas/recaptcha-shared.d.ts.map +1 -1
  81. package/dist/js/modules/captchas/recaptcha-v3.d.ts.map +1 -1
  82. package/dist/js/modules/fields/address-state.d.ts.map +1 -1
  83. package/dist/js/modules/fields/combobox.d.ts +2 -0
  84. package/dist/js/modules/fields/combobox.d.ts.map +1 -1
  85. package/dist/js/modules/fields/conditions.d.ts.map +1 -1
  86. package/dist/js/modules/fields/file-upload.d.ts.map +1 -1
  87. package/dist/js/modules/fields/phone-country.d.ts.map +1 -1
  88. package/dist/js/modules/fields/rich-text.d.ts.map +1 -1
  89. package/dist/js/modules/fields/summary.d.ts.map +1 -1
  90. package/dist/js/modules/fields/text-limit.d.ts.map +1 -1
  91. package/dist/js/modules/payments/factories.d.ts.map +1 -1
  92. package/dist/js/modules/payments/opayo.d.ts.map +1 -1
  93. package/dist/js/submit/pipeline.d.ts +2 -0
  94. package/dist/js/submit/pipeline.d.ts.map +1 -1
  95. package/dist/js/utils/form-started-at.d.ts +1 -0
  96. package/dist/js/utils/form-started-at.d.ts.map +1 -1
  97. package/dist/js/utils/unload-warning.d.ts +4 -0
  98. package/dist/js/utils/unload-warning.d.ts.map +1 -1
  99. package/dist/js/validation/rules/required.d.ts.map +1 -1
  100. package/dist/js/validation/submit-readiness.d.ts.map +1 -1
  101. package/dist/js/validation/validator.d.ts +1 -0
  102. package/dist/js/validation/validator.d.ts.map +1 -1
  103. package/package.json +3 -3
  104. package/src/index.ts +1 -0
  105. package/src/js/core/create-formie-client.ts +20 -18
  106. package/src/js/core/dom-events.ts +30 -1
  107. package/src/js/core/page-client-event.ts +5 -7
  108. package/src/js/core/submit-result-state.ts +13 -2
  109. package/src/js/core/submit-result-ui.ts +21 -0
  110. package/src/js/core/validation-focus.ts +17 -3
  111. package/src/js/modules/captchas/factories.ts +241 -101
  112. package/src/js/modules/captchas/host.ts +16 -0
  113. package/src/js/modules/captchas/recaptcha-enterprise.ts +1 -1
  114. package/src/js/modules/captchas/recaptcha-shared.ts +23 -5
  115. package/src/js/modules/captchas/recaptcha-v3.ts +23 -14
  116. package/src/js/modules/fields/address-state.ts +6 -4
  117. package/src/js/modules/fields/combobox.ts +19 -1
  118. package/src/js/modules/fields/conditions.ts +3 -0
  119. package/src/js/modules/fields/file-upload.ts +11 -4
  120. package/src/js/modules/fields/phone-country.ts +23 -0
  121. package/src/js/modules/fields/rich-text.ts +33 -0
  122. package/src/js/modules/fields/summary.ts +63 -10
  123. package/src/js/modules/fields/text-limit.ts +11 -0
  124. package/src/js/modules/payments/factories.ts +87 -54
  125. package/src/js/modules/payments/opayo.ts +7 -0
  126. package/src/js/submit/pipeline.ts +16 -9
  127. package/src/js/utils/form-started-at.ts +19 -4
  128. package/src/js/utils/unload-warning.ts +63 -7
  129. package/src/js/validation/rules/required.ts +6 -0
  130. package/src/js/validation/submit-readiness.ts +2 -0
  131. package/src/js/validation/validator.ts +15 -4
  132. package/src/vendor.d.ts +2 -0
  133. package/dist/chunks/opayo-BwWdInEP.js +0 -188
  134. package/dist/chunks/recaptcha-shared-DTbTw-MF.js +0 -27
  135. package/dist/chunks/recaptcha-v3-CflUHeZD.js +0 -28
@@ -14,20 +14,34 @@ function getFirstErroredField(form: HTMLFormElement): HTMLElement | null {
14
14
  }) || null;
15
15
  }
16
16
 
17
+ function getVisibleControl(control: HTMLElement): HTMLElement {
18
+ if (control instanceof HTMLTextAreaElement) {
19
+ const richText = (control as HTMLTextAreaElement & { richText?: { content: HTMLElement } }).richText;
20
+
21
+ if (richText) {
22
+ return richText.content;
23
+ }
24
+ }
25
+
26
+ return control;
27
+ }
28
+
17
29
  function getFieldFocusTarget(field: HTMLElement): HTMLElement | null {
18
30
  const invalidControl = Array.from(field.querySelectorAll('[aria-invalid="true"]')).find((node) => {
19
31
  return !isValidationSkipped(node);
20
32
  }) as HTMLElement | undefined;
21
33
 
22
34
  if (invalidControl) {
23
- return invalidControl;
35
+ return getVisibleControl(invalidControl);
24
36
  }
25
37
 
26
- return Array.from(field.querySelectorAll(
38
+ const control = Array.from(field.querySelectorAll(
27
39
  'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled])',
28
40
  )).find((node) => {
29
41
  return !isValidationSkipped(node);
30
- }) as HTMLElement | undefined ?? null;
42
+ }) as HTMLElement | undefined;
43
+
44
+ return control ? getVisibleControl(control) : null;
31
45
  }
32
46
 
33
47
  function getFormLevelErrorTarget(form: HTMLFormElement): HTMLElement | null {
@@ -16,6 +16,7 @@ import {
16
16
  } from '#modules/captchas/host';
17
17
  import { createDebug } from '#utils/debug';
18
18
  import { getFormStateEventName } from '#utils/event-names';
19
+ import type { FormUnloadWarningExclusionsEventDetail } from '#utils/unload-warning';
19
20
 
20
21
  type Cleanup = () => void;
21
22
  const debug = createDebug('captchas');
@@ -91,40 +92,112 @@ export function createCaptchaModule<TProvider extends Record<string, unknown> =
91
92
  surfaces: ['server-rendered', 'client-rendered'],
92
93
  kind: 'captcha',
93
94
  match: () => true,
94
- setup: async(ctx) => {
95
+ setup: async (ctx) => {
95
96
  // Every captcha module, passive or widget-backed, starts from the
96
97
  // same two building blocks:
97
98
  // 1. normalized options from the backend manifest
98
99
  // 2. shared host services for placeholders, errors, tokens, and
99
100
  // refresh-token events
100
- const options = normalizeCaptchaModuleOptions<TProvider>(moduleId.split(':')[1], ctx.options || {}, {
101
- defaultPlaceholderSelector,
102
- defaultTokenFieldNames,
103
- defaultWaitForValueMs,
104
- });
101
+ const options = normalizeCaptchaModuleOptions<TProvider>(
102
+ moduleId.split(':')[1],
103
+ ctx.options || {},
104
+ {
105
+ defaultPlaceholderSelector,
106
+ defaultTokenFieldNames,
107
+ defaultWaitForValueMs,
108
+ },
109
+ );
105
110
  debug.log('Setup module.', {
106
111
  moduleId: moduleId,
107
112
  placeholderSelector: options.ui.placeholderSelector,
108
113
  tokenFieldNames: options.transport.tokenFieldNames,
109
114
  });
110
115
  let placeholder: HTMLElement | undefined;
111
- if (ctx.surface === 'client-rendered' && !ctx.root.querySelector(options.ui.placeholderSelector)) {
112
- const attribute = /^\[(data-[a-z0-9-]+)\]$/.exec(options.ui.placeholderSelector)?.[1];
113
- if (!attribute) throw new Error('The CAPTCHA needs a supported placeholder in the client-rendered form.');
116
+ if (
117
+ ctx.surface === 'client-rendered' &&
118
+ !ctx.root.querySelector(options.ui.placeholderSelector)
119
+ ) {
120
+ const attribute = /^\[(data-[a-z0-9-]+)\]$/.exec(
121
+ options.ui.placeholderSelector,
122
+ )?.[1];
123
+ if (!attribute)
124
+ throw new Error(
125
+ 'The CAPTCHA needs a supported placeholder in the client-rendered form.',
126
+ );
114
127
  placeholder = document.createElement('div');
115
128
  placeholder.setAttribute(attribute, '');
116
129
  (ctx.form ?? ctx.root).append(placeholder);
117
130
  }
118
- const services = createCaptchaHostServices(ctx, options);
119
-
120
- const instance = await setup({
121
- ...ctx,
122
- options,
123
- services,
131
+ // Providers own their transport names. Keep refreshed names until
132
+ // teardown so removing or rotating a token cannot dirty the baseline.
133
+ const excludedFieldNames = new Set(
134
+ options.transport.tokenFieldNames,
135
+ );
136
+ let active = true;
137
+ const trackTokenNames = (names: string[]) => {
138
+ names
139
+ .filter(Boolean)
140
+ .forEach((name) => excludedFieldNames.add(name));
141
+ };
142
+ trackTokenNames(
143
+ options.transport.sessionKey
144
+ ? [options.transport.sessionKey]
145
+ : [],
146
+ );
147
+ const services = createCaptchaHostServices(ctx, options, {
148
+ onTokenFieldNames: trackTokenNames,
149
+ isActive: () => active,
124
150
  });
125
- if (!instance) { placeholder?.remove(); return; }
126
- const destroy = instance.destroy;
127
- return { ...instance, destroy: async() => { await destroy(); placeholder?.remove(); } };
151
+ const stopCollecting = services.events.onForm(
152
+ 'formie:unload-warning:collect-exclusions',
153
+ (event) => {
154
+ const detail = (
155
+ event as CustomEvent<FormUnloadWarningExclusionsEventDetail>
156
+ ).detail;
157
+ if (detail?.form === ctx.form) {
158
+ detail.exclude(...excludedFieldNames);
159
+ }
160
+ },
161
+ );
162
+ const stopTrackingRefreshes = services.refresh.onTokensRefreshed(
163
+ (entry) => {
164
+ if (
165
+ typeof entry.sessionKey === 'string' &&
166
+ entry.sessionKey.trim() !== ''
167
+ ) {
168
+ trackTokenNames([entry.sessionKey.trim()]);
169
+ }
170
+ },
171
+ );
172
+ const cleanup = () => {
173
+ active = false;
174
+ stopCollecting();
175
+ stopTrackingRefreshes();
176
+ placeholder?.remove();
177
+ };
178
+
179
+ try {
180
+ const instance = await setup({ ...ctx, options, services });
181
+ if (!instance) {
182
+ cleanup();
183
+ return;
184
+ }
185
+
186
+ const destroy = instance.destroy;
187
+ return {
188
+ ...instance,
189
+ destroy: async () => {
190
+ try {
191
+ await destroy();
192
+ } finally {
193
+ cleanup();
194
+ }
195
+ },
196
+ };
197
+ } catch (error) {
198
+ cleanup();
199
+ throw error;
200
+ }
128
201
  },
129
202
  };
130
203
  }
@@ -237,12 +310,14 @@ export function createManagedCaptchaModule<
237
310
  TProvider extends Record<string, unknown>,
238
311
  TApi,
239
312
  TWidget,
240
- >(adapter: ManagedCaptchaModuleAdapter<TProvider, TApi, TWidget>): BrowserModuleDefinition {
313
+ >(
314
+ adapter: ManagedCaptchaModuleAdapter<TProvider, TApi, TWidget>,
315
+ ): BrowserModuleDefinition {
241
316
  return createCaptchaModule<TProvider>({
242
317
  moduleId: adapter.moduleId,
243
318
  defaultPlaceholderSelector: adapter.defaultPlaceholderSelector,
244
319
  defaultTokenFieldNames: adapter.defaultTokenFieldNames,
245
- setup: async(ctx) => {
320
+ setup: async (ctx) => {
246
321
  // Managed providers are the widget/SDK-backed family. The factory
247
322
  // owns only the generic plumbing:
248
323
  // - load the provider API once
@@ -254,13 +329,15 @@ export function createManagedCaptchaModule<
254
329
  const cleanups: Cleanup[] = [];
255
330
  const mountedWidgets = new Map<HTMLElement, TWidget>();
256
331
  const mountPromises = new Map<HTMLElement, Promise<void>>();
332
+ const unmountPromises = new Map<HTMLElement, Promise<void>>();
333
+ let destructionPromise: Promise<void> | null = null;
257
334
  let activePlaceholder = ctx.services.placeholder.getPrimary();
258
335
  let destroyed = false;
259
336
  let initializationError: unknown;
260
337
 
261
338
  let apiPromise: Promise<TApi> | null = null;
262
339
 
263
- const getApi = async(): Promise<TApi> => {
340
+ const getApi = async (): Promise<TApi> => {
264
341
  if (!apiPromise) {
265
342
  debug.log('Loading captcha provider API.', {
266
343
  moduleId: adapter.moduleId,
@@ -271,55 +348,67 @@ export function createManagedCaptchaModule<
271
348
  return apiPromise;
272
349
  };
273
350
 
274
- const unmountPlaceholder = async(placeholder: HTMLElement) => {
275
- const widget = mountedWidgets.get(placeholder);
276
-
277
- ctx.services.errors.clear(placeholder);
278
-
279
- if (!widget) {
280
- placeholder.innerHTML = '';
281
- return;
282
- }
351
+ const unmountPlaceholder = (
352
+ placeholder: HTMLElement,
353
+ ): Promise<void> => {
354
+ const pending = unmountPromises.get(placeholder);
355
+ if (pending) return pending;
283
356
 
284
- const api = await getApi();
285
-
286
- if (adapter.unmount) {
287
- await adapter.unmount({
288
- api,
289
- widget,
290
- placeholder,
291
- services: ctx.services,
292
- options: ctx.options,
293
- provider: ctx.options.provider,
294
- });
295
- }
296
-
297
- mountedWidgets.delete(placeholder);
298
- placeholder.innerHTML = '';
299
- ctx.services.tokens.clear();
300
- debug.log('Unmounted captcha placeholder widget.', {
301
- moduleId: adapter.moduleId,
302
- });
357
+ const promise = (async () => {
358
+ // A widget can finish mounting after its placeholder hides or
359
+ // teardown starts. Wait so its provider resources are released too.
360
+ await mountPromises.get(placeholder)?.catch(() => {});
361
+ ctx.services.errors.clear(placeholder);
362
+ const hasWidget = mountedWidgets.has(placeholder);
363
+ const widget = mountedWidgets.get(placeholder) as TWidget;
364
+ mountedWidgets.delete(placeholder);
365
+
366
+ try {
367
+ if (hasWidget && adapter.unmount) {
368
+ const api = await getApi();
369
+ await adapter.unmount({
370
+ api,
371
+ widget,
372
+ placeholder,
373
+ services: ctx.services,
374
+ options: ctx.options,
375
+ provider: ctx.options.provider,
376
+ });
377
+ }
378
+ } finally {
379
+ // Provider failures must not leave transport values or DOM
380
+ // behind, or prevent another widget from being cleaned up.
381
+ placeholder.innerHTML = '';
382
+ ctx.services.tokens.clear();
383
+ if (activePlaceholder === placeholder) {
384
+ activePlaceholder =
385
+ ctx.services.placeholder.getPrimary();
386
+ }
387
+ }
388
+ })().finally(() => unmountPromises.delete(placeholder));
303
389
 
304
- if (activePlaceholder === placeholder) {
305
- activePlaceholder = ctx.services.placeholder.getPrimary();
306
- }
390
+ unmountPromises.set(placeholder, promise);
391
+ return promise;
307
392
  };
308
393
 
309
- const mountPlaceholder = async(placeholder: HTMLElement) => {
394
+ const mountPlaceholder = async (placeholder: HTMLElement) => {
395
+ const unmounting = unmountPromises.get(placeholder);
396
+ if (unmounting) await unmounting;
310
397
  if (destroyed || mountedWidgets.has(placeholder)) return;
311
- if (mountPromises.has(placeholder)) return mountPromises.get(placeholder);
398
+ if (mountPromises.has(placeholder))
399
+ return mountPromises.get(placeholder);
312
400
 
313
401
  // Guard against repeated visibility events or rapid DOM churn
314
402
  // by tracking an in-flight mount promise per placeholder.
315
- const promise = (async() => {
403
+ const promise = (async () => {
316
404
  const api = await getApi();
317
405
 
318
406
  if (destroyed || mountedWidgets.has(placeholder)) {
319
407
  return;
320
408
  }
321
409
 
322
- const container = ctx.services.placeholder.createContainer(placeholder);
410
+ const container =
411
+ ctx.services.placeholder.createContainer(placeholder);
323
412
  // Providers always render into a fresh container so the
324
413
  // shared services can safely tear down and rebuild
325
414
  // placeholder DOM.
@@ -349,16 +438,25 @@ export function createManagedCaptchaModule<
349
438
  const visibility = ctx.services.placeholder.observe(
350
439
  (placeholder) => {
351
440
  activePlaceholder = placeholder;
352
- void mountPlaceholder(placeholder).catch((error) => { initializationError = error; });
441
+ void mountPlaceholder(placeholder).catch((error) => {
442
+ initializationError = error;
443
+ });
353
444
  },
354
445
  (placeholder) => {
355
- void unmountPlaceholder(placeholder);
446
+ if (!destroyed) {
447
+ void unmountPlaceholder(placeholder).catch((error) => {
448
+ initializationError = error;
449
+ });
450
+ }
356
451
  },
357
452
  );
358
453
 
359
454
  cleanups.push(visibility.cleanup);
360
455
 
361
- const rerenderVisiblePlaceholders = async(reason: 'submit-result' | 'reset-state') => {
456
+ const rerenderVisiblePlaceholders = async (
457
+ reason: 'submit-result' | 'reset-state',
458
+ ) => {
459
+ if (destroyed) return;
362
460
  // Some providers can cheaply reset in-place, while others are
363
461
  // safer to unmount and mount again. The provider advertises
364
462
  // that through the optional `reset()` hook.
@@ -369,9 +467,9 @@ export function createManagedCaptchaModule<
369
467
  const api = await getApi();
370
468
 
371
469
  for (const placeholder of placeholdersToMount) {
372
- const widget = mountedWidgets.get(placeholder);
470
+ const widget = mountedWidgets.get(placeholder) as TWidget;
373
471
 
374
- if (!widget) {
472
+ if (!mountedWidgets.has(placeholder)) {
375
473
  await mountPlaceholder(placeholder);
376
474
  continue;
377
475
  }
@@ -405,57 +503,94 @@ export function createManagedCaptchaModule<
405
503
  visibility.reconcile();
406
504
  };
407
505
 
408
- cleanups.push(ctx.services.events.onRoot('formie:submit:result', (event) => {
409
- const detail = event instanceof CustomEvent ? event.detail as FormSubmitResult : null;
506
+ cleanups.push(
507
+ ctx.services.events.onRoot('formie:submit:result', (event) => {
508
+ const detail =
509
+ event instanceof CustomEvent
510
+ ? (event.detail as FormSubmitResult)
511
+ : null;
410
512
 
411
- if (detail?.stage === 'validate') {
412
- return;
413
- }
513
+ if (detail?.stage === 'validate') {
514
+ return;
515
+ }
414
516
 
415
- if (detail?.ok === false && detail?.stage === 'challenge') {
416
- return;
417
- }
517
+ if (detail?.ok === false && detail?.stage === 'challenge') {
518
+ return;
519
+ }
418
520
 
419
- if (detail?.ok === true) {
420
- return;
421
- }
521
+ if (detail?.ok === true) {
522
+ return;
523
+ }
422
524
 
423
- void rerenderVisiblePlaceholders('submit-result');
424
- }));
525
+ void rerenderVisiblePlaceholders('submit-result');
526
+ }),
527
+ );
425
528
 
426
529
  if (ctx.form) {
427
- cleanups.push(ctx.services.events.onForm(getFormStateEventName('reset'), () => {
428
- activePlaceholder = ctx.services.placeholder.getPrimary() || activePlaceholder;
429
-
430
- window.setTimeout(() => {
431
- void rerenderVisiblePlaceholders('reset-state');
432
- }, 0);
433
- }));
530
+ cleanups.push(
531
+ ctx.services.events.onForm(
532
+ getFormStateEventName('reset'),
533
+ () => {
534
+ activePlaceholder =
535
+ ctx.services.placeholder.getPrimary() ||
536
+ activePlaceholder;
537
+
538
+ window.setTimeout(() => {
539
+ void rerenderVisiblePlaceholders('reset-state');
540
+ }, 0);
541
+ },
542
+ ),
543
+ );
434
544
  }
435
545
 
546
+ const destroy = (): Promise<void> => {
547
+ if (destructionPromise) return destructionPromise;
548
+ destroyed = true;
549
+ destructionPromise = (async () => {
550
+ cleanups.forEach((cleanup) => cleanup());
551
+ await Promise.allSettled([...mountPromises.values()]);
552
+ const results = await Promise.allSettled(
553
+ new Set([
554
+ ...unmountPromises.values(),
555
+ ...Array.from(
556
+ mountedWidgets.keys(),
557
+ unmountPlaceholder,
558
+ ),
559
+ ]),
560
+ );
561
+ const errors = results.flatMap((result) =>
562
+ result.status === 'rejected' ? [result.reason] : [],
563
+ );
564
+ if (errors.length)
565
+ throw new AggregateError(
566
+ errors,
567
+ 'CAPTCHA widgets could not clean up.',
568
+ );
569
+ })();
570
+ return destructionPromise;
571
+ };
572
+
436
573
  visibility.reconcileImmediate();
437
574
  try {
438
575
  await Promise.all([...mountPromises.values()]);
439
576
  if (initializationError) throw initializationError;
440
577
  } catch (error) {
441
- destroyed = true;
442
- cleanups.forEach((cleanup) => cleanup());
578
+ // Setup can fail after another placeholder has already mounted.
579
+ // Preserve the setup error while still releasing every widget.
580
+ await destroy().catch((cleanupError) => {
581
+ debug.warn('Captcha cleanup after failed setup.', {
582
+ error: cleanupError,
583
+ });
584
+ });
443
585
  throw error;
444
586
  }
445
587
 
446
588
  return {
447
- assertReady: () => { if (initializationError) throw initializationError; },
448
- destroy: async() => {
449
- destroyed = true;
450
- cleanups.forEach((cleanup) => {
451
- cleanup();
452
- });
453
-
454
- for (const placeholder of Array.from(mountedWidgets.keys())) {
455
- await unmountPlaceholder(placeholder);
456
- }
589
+ assertReady: () => {
590
+ if (initializationError) throw initializationError;
457
591
  },
458
- beforeSubmit: async(stageCtx) => {
592
+ destroy,
593
+ beforeSubmit: async (stageCtx) => {
459
594
  if (stageCtx.action !== 'submit') {
460
595
  return;
461
596
  }
@@ -475,22 +610,27 @@ export function createManagedCaptchaModule<
475
610
  return;
476
611
  }
477
612
 
478
- let placeholder = visiblePlaceholders.find((candidate) => candidate === activePlaceholder)
479
- || visiblePlaceholders[0];
613
+ let placeholder =
614
+ visiblePlaceholders.find(
615
+ (candidate) => candidate === activePlaceholder,
616
+ ) || visiblePlaceholders[0];
480
617
 
481
618
  await mountPlaceholder(placeholder);
482
619
  placeholder = activePlaceholder || placeholder;
483
620
 
484
621
  ctx.services.errors.clear(placeholder);
485
622
 
486
- const widget = mountedWidgets.get(placeholder);
623
+ const widget = mountedWidgets.get(placeholder) as TWidget;
487
624
 
488
- if (!widget) {
625
+ if (!mountedWidgets.has(placeholder)) {
489
626
  const message = ctx.services.errors.getDefaultMessage();
490
627
  ctx.services.errors.show(message, placeholder);
491
- debug.warn('Captcha widget unavailable at challenge stage.', {
492
- moduleId: adapter.moduleId,
493
- });
628
+ debug.warn(
629
+ 'Captcha widget unavailable at challenge stage.',
630
+ {
631
+ moduleId: adapter.moduleId,
632
+ },
633
+ );
494
634
  stageCtx.abort(message);
495
635
  return;
496
636
  }
@@ -237,8 +237,11 @@ function observeVisiblePlaceholders(
237
237
  onHide: (placeholder: HTMLElement) => void,
238
238
  ): ObserveVisiblePlaceholdersResult {
239
239
  const visible = new Set<HTMLElement>();
240
+ let active = true;
240
241
 
241
242
  const reconcileNow = () => {
243
+ // A debounced observer callback may already be queued when cleanup runs.
244
+ if (!active) return;
242
245
  const placeholders = queryCaptchaPlaceholders(root, selector);
243
246
  const nextVisible = new Set(placeholders.filter((placeholder) => isElementVisible(placeholder)));
244
247
 
@@ -291,6 +294,8 @@ function observeVisiblePlaceholders(
291
294
 
292
295
  return {
293
296
  cleanup: () => {
297
+ if (!active) return;
298
+ active = false;
294
299
  mutationObserver.disconnect();
295
300
  cleanups.forEach((cleanup) => {
296
301
  cleanup();
@@ -371,6 +376,10 @@ export function normalizeCaptchaModuleOptions<TProvider extends Record<string, u
371
376
  export function createCaptchaHostServices<TProvider extends Record<string, unknown>>(
372
377
  ctx: ModuleSetupContext,
373
378
  options: NormalizedCaptchaModuleOptions<TProvider>,
379
+ lifecycle: {
380
+ onTokenFieldNames?: (names: string[]) => void;
381
+ isActive?: () => boolean;
382
+ } = {},
374
383
  ): CaptchaHostServices {
375
384
  const tokenRoot = ctx.form || ctx.root;
376
385
  const placeholderSelector = options.ui.placeholderSelector;
@@ -396,9 +405,11 @@ export function createCaptchaHostServices<TProvider extends Record<string, unkno
396
405
  errors: {
397
406
  getDefaultMessage: () => options.ui.errorMessage,
398
407
  show: (message, placeholder) => {
408
+ if (lifecycle.isActive?.() === false) return;
399
409
  showCaptchaError(placeholder || getPrimaryPlaceholder(ctx.root, placeholderSelector), message || options.ui.errorMessage, ctx.form || ctx.root);
400
410
  },
401
411
  clear: (placeholder) => {
412
+ if (lifecycle.isActive?.() === false) return;
402
413
  clearCaptchaError(placeholder || getPrimaryPlaceholder(ctx.root, placeholderSelector));
403
414
  },
404
415
  },
@@ -411,6 +422,10 @@ export function createCaptchaHostServices<TProvider extends Record<string, unkno
411
422
  root = tokenRoot,
412
423
  container = ctx.form,
413
424
  } = {}) => {
425
+ // Late SDK callbacks must not mutate an unmounted form or a new
426
+ // instance that now owns the same transport names.
427
+ if (lifecycle.isActive?.() === false) return;
428
+ lifecycle.onTokenFieldNames?.(names);
414
429
  names.forEach((name) => {
415
430
  ensureCaptchaValueInput(root, name, {
416
431
  value,
@@ -419,6 +434,7 @@ export function createCaptchaHostServices<TProvider extends Record<string, unkno
419
434
  });
420
435
  },
421
436
  clear: (names = options.transport.tokenFieldNames, root = tokenRoot) => {
437
+ if (lifecycle.isActive?.() === false) return;
422
438
  clearCaptchaValues(root, names);
423
439
  },
424
440
  wait: (timeoutMs = options.transport.waitForValueMs, names = options.transport.tokenFieldNames, root = tokenRoot) => {
@@ -109,7 +109,7 @@ export const recaptchaEnterpriseModule = defineCaptchaModule<RecaptchaEnterprise
109
109
  // Fallback branch for any future Enterprise widget-like mode.
110
110
  enterpriseApi.execute(widget);
111
111
  }
112
- });
112
+ }, true);
113
113
 
114
114
  const hasToken = await services.tokens.wait(CAPTCHA_EXECUTE_WAIT_FOR_VALUE_MS);
115
115
 
@@ -1,4 +1,5 @@
1
- import { loadScriptAndEnsureGlobal } from '#utils/scripts';
1
+ import { loadExternalScript } from '#utils/scripts';
2
+ import { waitFor } from '#utils/async';
2
3
  import { CAPTCHA_PROVIDER_LOAD_TIMEOUT_MS } from '#modules/captchas/constants';
3
4
  import { getScriptAttributes } from '#modules/captchas/utils';
4
5
 
@@ -59,20 +60,37 @@ export async function loadRecaptchaGlobal(
59
60
  src.searchParams.set('badge', options.badge.trim());
60
61
  }
61
62
 
62
- return loadScriptAndEnsureGlobal<RecaptchaGlobal>('grecaptcha', {
63
+ // Both SDKs share `grecaptcha`, but loading one does not load the other.
64
+ // A ready() stub can also exist before either SDK has attached its methods.
65
+ const getLoadedApi = (): RecaptchaGlobal | null => {
66
+ const api = (window as Window & { grecaptcha?: RecaptchaGlobal }).grecaptcha;
67
+ const target = enterprise ? api?.enterprise : api;
68
+
69
+ return api && target && typeof target.ready === 'function' && typeof target.execute === 'function'
70
+ && typeof target.render === 'function' ? api : null;
71
+ };
72
+ const existing = getLoadedApi();
73
+
74
+ if (existing) {
75
+ return existing;
76
+ }
77
+
78
+ await loadExternalScript({
63
79
  id: enterprise ? 'FORMIE_RECAPTCHA_ENTERPRISE_SCRIPT' : 'FORMIE_RECAPTCHA_SCRIPT',
64
80
  src: src.toString(),
65
81
  async,
66
82
  defer,
67
- timeoutMs: CAPTCHA_PROVIDER_LOAD_TIMEOUT_MS,
68
83
  });
84
+
85
+ return waitFor(getLoadedApi, { timeoutMs: CAPTCHA_PROVIDER_LOAD_TIMEOUT_MS });
69
86
  }
70
87
 
71
- export function whenRecaptchaReady(api: RecaptchaGlobal, callback: () => void | Promise<void>): Promise<void> {
72
- const readyApi = api.enterprise || api;
88
+ export function whenRecaptchaReady(api: RecaptchaGlobal, callback: () => void | Promise<void>, enterprise = false): Promise<void> {
89
+ const readyApi = enterprise ? api.enterprise : api;
73
90
 
74
91
  return new Promise((resolve, reject) => {
75
92
  try {
93
+ if (!readyApi) throw new Error('The reCAPTCHA API is unavailable.');
76
94
  readyApi.ready(() => {
77
95
  Promise.resolve(callback()).then(resolve).catch(reject);
78
96
  });