fontdue-js 3.2.0 → 3.2.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 3.2.2
2
+
3
+ - **Fixed the reCAPTCHA forms failing to server-render under Vite SSR** (Astro, and any framework whose dev SSR externalizes CJS deps through Node's ESM interop). 3.2.1 switched to `react-google-recaptcha`'s default export, which bundlers honouring the `__esModule` marker (webpack/Next, and Vite's browser optimizer) unwrap straight to the widget wrapper — but Node's strict ESM↔CJS interop models that same default import as the whole module *namespace*, so `<ReCAPTCHA>` was an object at SSR time. React threw "Element type is invalid … got: object" and the checkout, `NewsletterSignup` and `TestFontsForm` islands fell back to client rendering with a console error. The re-export now normalises the binding (`__esModule ? .default : obj`), resolving the wrapper on every path. A no-op for webpack and Vite's browser build. No public API change.
4
+
5
+ ## 3.2.1
6
+
7
+ - **Fixed checkout, newsletter signup and trial-font downloads hanging forever on reCAPTCHA-enabled foundries.** `StoreModalUnifiedCheckout`, `NewsletterSignup` and `TestFontsForm` imported the *named* `ReCAPTCHA` export from `react-google-recaptcha` — the barebone widget, which never injects Google's `api.js` and expects you to supply `grecaptcha` yourself. So the invisible widget rendered an empty `<div>`, `window.grecaptcha` stayed undefined, `execute()` was a permanent silent no-op, and the submit button froze on "Submitting…" with no network request and no error. All three forms now use the package's default export (the `react-async-script` wrapper that actually loads the script). This has affected every release since 3.0.0. As defence in depth, each form also arms a 15-second timeout after calling `execute()`: if the reCAPTCHA script never loaded, the form resets and surfaces an inline error so the buyer can retry, while a challenge already in progress is never cancelled. No public API change — no new or changed props.
8
+ - **Redesigned the admin preview toolbar** (`FontdueAdminToolbar`) — the collapsed control is now a bottom-centre pill carrying its state in the label (*Preview* / *Previewing hidden fonts* / *Preview expired*), and the panel leads with the *Hidden fonts* switch. The toolbar chrome no longer inherits the host page's font. If you style the toolbar yourself, note the new element class names (`__brand`, `__mark`, `__toggle-text`, `__switch`, `__status`) and that `__dot` is gone; `data-testid="preview-toggle"` is unchanged.
9
+ - `TypeTester` standalone element: comma-separated `axes`, `features` and `features-selected` attributes are now trimmed, so `axes="wght, opsz"` behaves the same as `axes="wght,opsz"` and a trailing comma no longer creates an empty entry (`features="*"` is preserved). Requesting an axis the bound style doesn't expose now logs a console warning naming the axes it does expose, instead of silently rendering no sliders.
10
+ - **Unlocking a password-protected collection no longer slows down the rest of the site for that visitor.** The unlock route enabled Next draft mode with its default site-wide bypass cookie (`Path=/`), so after one unlock *every* page the visitor touched skipped the full-route cache and the data cache and was server-rendered live — seconds per page on content-heavy pages. `NodePasswordForm` now sends the pathname it unlocked on, and the unlock route scopes the bypass cookie to it: only the unlocked collection's pages render dynamically for that visitor, and everything else keeps serving from the static cache. Unlocking several collections yields several path-scoped cookies that coexist. Admin preview is unchanged — its bypass stays site-wide, which is the point of previewing.
11
+
1
12
  ## 3.2.0
2
13
 
3
14
  - **Fixed password-protected collections crashing statically-rendered Next.js storefronts.** Locked slugs are excluded from `generateStaticParams`, so a visitor's first request for one triggers an on-demand static fill of the font page — and the unlock-cookie read during that fill crashed it with a 500 (digest `DYNAMIC_SERVER_USAGE`). A locked page's static fill now renders (and caches) the password form, and unlocking takes that visitor past the static cache instead — the same mechanism as admin preview:
@@ -44,18 +44,31 @@ vi.mock('next/navigation', () => ({
44
44
  // Draft mode + the preview token cookie, controllable per test. Default: not
45
45
  // previewing, so __prepareFontdueRender takes the public (cached) path. Set
46
46
  // cookiesError to make cookies() throw (e.g. simulate Next's dynamic bailout
47
- // during a prerender pass).
47
+ // during a prerender pass). The jar mirrors Next's mutable route-handler
48
+ // cookie store: draftMode().enable() queues the site-wide bypass cookie in it
49
+ // (exactly the attributes real enable() sets), cookies().set() with the same
50
+ // name replaces the queued cookie, and cookies().get() sees pending
51
+ // mutations — the semantics the unlock route's path-scoping relies on.
48
52
  const draft = vi.hoisted(() => ({
49
53
  enabled: false,
50
54
  token: undefined,
51
55
  nodeAccess: undefined,
52
- cookiesError: undefined
56
+ cookiesError: undefined,
57
+ jar: new Map()
53
58
  }));
54
59
  vi.mock('next/headers', () => ({
55
60
  draftMode: async () => ({
56
61
  isEnabled: draft.enabled,
57
62
  enable: () => {
58
63
  draft.enabled = true;
64
+ draft.jar.set('__prerender_bypass', {
65
+ name: '__prerender_bypass',
66
+ value: 'test-preview-mode-id',
67
+ httpOnly: true,
68
+ sameSite: 'none',
69
+ secure: true,
70
+ path: '/'
71
+ });
59
72
  }
60
73
  }),
61
74
  cookies: async () => {
@@ -65,7 +78,10 @@ vi.mock('next/headers', () => ({
65
78
  value: draft.token
66
79
  } : name === 'fontdue_node_access' && draft.nodeAccess ? {
67
80
  value: draft.nodeAccess
68
- } : undefined
81
+ } : draft.jar.get(name),
82
+ set: cookie => {
83
+ draft.jar.set(cookie.name, cookie);
84
+ }
69
85
  };
70
86
  }
71
87
  }));
@@ -82,6 +98,7 @@ beforeEach(() => {
82
98
  draft.token = undefined;
83
99
  draft.nodeAccess = undefined;
84
100
  draft.cookiesError = undefined;
101
+ draft.jar.clear();
85
102
  });
86
103
  function stubSingleTenant() {
87
104
  let url = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'https://acme.fontdue.com';
@@ -457,6 +474,49 @@ describe('unlock route handler', () => {
457
474
  });
458
475
  expect(draft.enabled).toBe(true);
459
476
  });
477
+ it('scopes the bypass cookie to the posted path, replacing the site-wide one', async () => {
478
+ const {
479
+ POST
480
+ } = await importUnlock();
481
+ const response = await POST(unlockRequest(JSON.stringify({
482
+ token: 'na-tok',
483
+ path: '/fonts/recursive'
484
+ })));
485
+ expect(response.status).toBe(200);
486
+ expect(draft.enabled).toBe(true);
487
+ const bypass = draft.jar.get('__prerender_bypass');
488
+ expect(bypass === null || bypass === void 0 ? void 0 : bypass.path).toBe('/fonts/recursive');
489
+ // Replaced, not duplicated — and the value + protective attributes from
490
+ // enable() survive the re-set.
491
+ expect(bypass === null || bypass === void 0 ? void 0 : bypass.value).toBe('test-preview-mode-id');
492
+ expect(bypass === null || bypass === void 0 ? void 0 : bypass.httpOnly).toBe(true);
493
+ expect(bypass === null || bypass === void 0 ? void 0 : bypass.sameSite).toBe('none');
494
+ expect(bypass === null || bypass === void 0 ? void 0 : bypass.secure).toBe(true);
495
+ });
496
+ it('strips trailing slashes so the cookie still matches the page itself', async () => {
497
+ var _draft$jar$get;
498
+ const {
499
+ POST
500
+ } = await importUnlock();
501
+ await POST(unlockRequest(JSON.stringify({
502
+ token: 'na-tok',
503
+ path: '/fonts/recursive/'
504
+ })));
505
+ expect((_draft$jar$get = draft.jar.get('__prerender_bypass')) === null || _draft$jar$get === void 0 ? void 0 : _draft$jar$get.path).toBe('/fonts/recursive');
506
+ });
507
+ it.each([['missing', undefined], ['not a string', 42], ['relative', 'fonts/recursive'], ['attribute injection', '/fonts/x; SameSite=Lax'], ['not percent-encoded (space)', '/fonts/ x'], ['control characters', '/fonts/\u0000x'], ['root (nothing to narrow)', '/'], ['over-long', '/' + 'x'.repeat(600)]])('leaves the bypass site-wide when the path is %s', async (_label, path) => {
508
+ var _draft$jar$get2;
509
+ const {
510
+ POST
511
+ } = await importUnlock();
512
+ const response = await POST(unlockRequest(JSON.stringify({
513
+ token: 'na-tok',
514
+ path
515
+ })));
516
+ expect(response.status).toBe(200);
517
+ expect(draft.enabled).toBe(true);
518
+ expect((_draft$jar$get2 = draft.jar.get('__prerender_bypass')) === null || _draft$jar$get2 === void 0 ? void 0 : _draft$jar$get2.path).toBe('/');
519
+ });
460
520
  it('rejects a missing token without enabling the bypass', async () => {
461
521
  const {
462
522
  POST
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { createRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_TIMEOUT_MS, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../hooks/useRecaptchaTimeout.js';
3
+
4
+ // FD-917: on a reCAPTCHA-enabled tenant the checkout / newsletter / test-fonts
5
+ // submit handlers call `recaptchaRef.current.execute()` and then wait for the
6
+ // widget's `onChange` to hand back a token. If the invisible widget never loads
7
+ // (Google's api.js is never injected — reproduced live on Big Fog Foundry),
8
+ // `execute()` is a silent no-op, `onChange` never fires, and the button sits on
9
+ // “Submitting…” forever with no error and no network request.
10
+ //
11
+ // The fix is a timeout guard (`createRecaptchaTimeout`) plus a readiness probe
12
+ // (`isRecaptchaScriptLoaded`). These tests model the submit control flow to
13
+ // pin the contract: a widget that never responds must not hang forever, and a
14
+ // widget that DID load (buyer possibly mid-challenge) must not be cancelled.
15
+
16
+ // Minimal model of a submit handler's reCAPTCHA-gated path, mirroring
17
+ // handleCustomerSubmit + handleRecaptchaChange in StoreModalUnifiedCheckout.
18
+ function makeSubmitHarness(_ref) {
19
+ let {
20
+ widgetEverLoads,
21
+ fireTokenAfterMs
22
+ } = _ref;
23
+ const timeout = createRecaptchaTimeout();
24
+ const state = {
25
+ submitting: false,
26
+ pending: false,
27
+ error: null,
28
+ submittedToken: undefined,
29
+ resets: 0
30
+ };
31
+ const onChange = token => {
32
+ if (token && state.pending) {
33
+ timeout.clear();
34
+ state.pending = false;
35
+ state.submitting = false;
36
+ state.submittedToken = token;
37
+ }
38
+ };
39
+ const abort = () => {
40
+ timeout.clear();
41
+ state.pending = false;
42
+ state.submitting = false;
43
+ state.resets += 1;
44
+ state.error = RECAPTCHA_UNAVAILABLE_MESSAGE;
45
+ };
46
+ const execute = () => {
47
+ if (widgetEverLoads) {
48
+ globalThis.grecaptcha = {
49
+ ready: true
50
+ };
51
+ if (fireTokenAfterMs != null) {
52
+ setTimeout(() => onChange('tok_ok'), fireTokenAfterMs);
53
+ }
54
+ }
55
+ // Dead widget: execute() is a no-op, onChange never fires.
56
+ };
57
+
58
+ // Mirrors the guarded submit branch: mark submitting/pending, arm the safety
59
+ // net, then execute.
60
+ const submit = () => {
61
+ state.submitting = true;
62
+ state.pending = true;
63
+ state.error = null;
64
+ timeout.start(() => {
65
+ if (isRecaptchaScriptLoaded()) return; // may be mid-challenge; keep waiting
66
+ abort();
67
+ });
68
+ execute();
69
+ };
70
+ return {
71
+ state,
72
+ submit
73
+ };
74
+ }
75
+ describe('reCAPTCHA resilience (FD-917)', () => {
76
+ beforeEach(() => {
77
+ vi.useFakeTimers();
78
+ delete globalThis.grecaptcha;
79
+ });
80
+ afterEach(() => {
81
+ vi.useRealTimers();
82
+ delete globalThis.grecaptcha;
83
+ });
84
+ it('dead widget: resets and shows an error instead of hanging forever', () => {
85
+ const {
86
+ state,
87
+ submit
88
+ } = makeSubmitHarness({
89
+ widgetEverLoads: false
90
+ });
91
+ submit();
92
+ // Immediately after submit the button is stuck on “Submitting…”.
93
+ expect(state.submitting).toBe(true);
94
+ expect(state.pending).toBe(true);
95
+ expect(state.error).toBeNull();
96
+
97
+ // Advance well past any real challenge — the old code would still be stuck.
98
+ vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
99
+ expect(state.submitting).toBe(false);
100
+ expect(state.pending).toBe(false);
101
+ expect(state.resets).toBe(1);
102
+ expect(state.error).toBe(RECAPTCHA_UNAVAILABLE_MESSAGE);
103
+ });
104
+ it('happy path: a token before the timeout submits and never triggers the guard', () => {
105
+ const {
106
+ state,
107
+ submit
108
+ } = makeSubmitHarness({
109
+ widgetEverLoads: true,
110
+ fireTokenAfterMs: 1500
111
+ });
112
+ submit();
113
+ vi.advanceTimersByTime(1500);
114
+ expect(state.submittedToken).toBe('tok_ok');
115
+ expect(state.submitting).toBe(false);
116
+ expect(state.pending).toBe(false);
117
+ expect(state.error).toBeNull();
118
+
119
+ // The timeout must not fire after a successful submit.
120
+ vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
121
+ expect(state.resets).toBe(0);
122
+ });
123
+ it('slow challenge: a loaded widget is not cancelled while awaiting the buyer', () => {
124
+ // Widget loaded (grecaptcha defined) but the token takes longer than the
125
+ // timeout — e.g. the buyer is solving an image challenge. We must keep
126
+ // waiting rather than resetting under them.
127
+ const {
128
+ state,
129
+ submit
130
+ } = makeSubmitHarness({
131
+ widgetEverLoads: true,
132
+ fireTokenAfterMs: RECAPTCHA_TIMEOUT_MS + 5000
133
+ });
134
+ submit();
135
+ // Cross the timeout while the challenge is still open.
136
+ vi.advanceTimersByTime(RECAPTCHA_TIMEOUT_MS + 1000);
137
+ expect(state.resets).toBe(0);
138
+ expect(state.pending).toBe(true);
139
+
140
+ // The buyer finishes the challenge; the token still submits.
141
+ vi.advanceTimersByTime(4000);
142
+ expect(state.submittedToken).toBe('tok_ok');
143
+ expect(state.submitting).toBe(false);
144
+ expect(state.pending).toBe(false);
145
+ });
146
+ });
147
+ describe('createRecaptchaTimeout', () => {
148
+ beforeEach(() => vi.useFakeTimers());
149
+ afterEach(() => vi.useRealTimers());
150
+ it('fires onTimeout after the delay', () => {
151
+ const timeout = createRecaptchaTimeout(1000);
152
+ const cb = vi.fn();
153
+ timeout.start(cb);
154
+ vi.advanceTimersByTime(999);
155
+ expect(cb).not.toHaveBeenCalled();
156
+ vi.advanceTimersByTime(1);
157
+ expect(cb).toHaveBeenCalledTimes(1);
158
+ });
159
+ it('clear() cancels a pending timer', () => {
160
+ const timeout = createRecaptchaTimeout(1000);
161
+ const cb = vi.fn();
162
+ timeout.start(cb);
163
+ timeout.clear();
164
+ vi.advanceTimersByTime(5000);
165
+ expect(cb).not.toHaveBeenCalled();
166
+ });
167
+ it('start() replaces a previously scheduled timer', () => {
168
+ const timeout = createRecaptchaTimeout(1000);
169
+ const first = vi.fn();
170
+ const second = vi.fn();
171
+ timeout.start(first);
172
+ vi.advanceTimersByTime(500);
173
+ timeout.start(second);
174
+ vi.advanceTimersByTime(1000);
175
+ expect(first).not.toHaveBeenCalled();
176
+ expect(second).toHaveBeenCalledTimes(1);
177
+ });
178
+ });
179
+ describe('isRecaptchaScriptLoaded', () => {
180
+ afterEach(() => {
181
+ delete globalThis.grecaptcha;
182
+ });
183
+ it('is false when grecaptcha is absent', () => {
184
+ delete globalThis.grecaptcha;
185
+ expect(isRecaptchaScriptLoaded()).toBe(false);
186
+ });
187
+ it('is true once grecaptcha is present', () => {
188
+ globalThis.grecaptcha = {};
189
+ expect(isRecaptchaScriptLoaded()).toBe(true);
190
+ });
191
+ });
@@ -48,9 +48,9 @@ function ConnectionErrorToolbar(_ref) {
48
48
  className: "fontdue-admin-toolbar__error-status-text"
49
49
  }, /*#__PURE__*/React.createElement(WarningIcon, null), "Fontdue couldn\u2019t be reached from this site."), /*#__PURE__*/React.createElement("p", {
50
50
  className: "fontdue-admin-toolbar__hint"
51
- }, "This is most likely a cross-origin (CORS) setting \u2014 ", origin, " isn\u2019t on Fontdue\u2019s allowed list. Add it under Settings \u2192 Integration and this page will reload automatically."), /*#__PURE__*/React.createElement("p", {
51
+ }, "This is most likely a cross-origin (CORS) setting \u2013 ", origin, " isn\u2019t on Fontdue\u2019s allowed list. Add it under Settings \u2192 Integration and this page will reload automatically."), /*#__PURE__*/React.createElement("p", {
52
52
  className: "fontdue-admin-toolbar__hint"
53
- }, "If it\u2019s already listed, the connection may be failing for another reason \u2014 a network problem or a temporary Fontdue outage."), fixUrl && /*#__PURE__*/React.createElement("a", {
53
+ }, "If it\u2019s already listed, the connection may be failing for another reason \u2013 a network problem or a temporary Fontdue outage."), fixUrl && /*#__PURE__*/React.createElement("a", {
54
54
  className: "fontdue-admin-toolbar__action",
55
55
  href: fixUrl,
56
56
  target: "_blank",
@@ -139,7 +139,7 @@ export default function FontdueAdminToolbar() {
139
139
  if (isSessionExpiredError(errors)) {
140
140
  setPreviewMarkerCookie(false);
141
141
  setPreviewState('off');
142
- setError('Your session expired sign in again.');
142
+ setError('Your session expired, please sign in again.');
143
143
  } else {
144
144
  setError('Could not start preview.');
145
145
  }
@@ -204,8 +204,8 @@ export default function FontdueAdminToolbar() {
204
204
  }, /*#__PURE__*/React.createElement("div", {
205
205
  className: "fontdue-admin-toolbar__header"
206
206
  }, /*#__PURE__*/React.createElement("span", {
207
- className: "fontdue-admin-toolbar__title"
208
- }, "Fontdue"), /*#__PURE__*/React.createElement("span", {
207
+ className: "fontdue-admin-toolbar__brand"
208
+ }, /*#__PURE__*/React.createElement(FontdueMark, null), "Preview"), /*#__PURE__*/React.createElement("span", {
209
209
  className: "fontdue-admin-toolbar__user"
210
210
  }, adminName)), expired ? /*#__PURE__*/React.createElement("div", {
211
211
  className: "fontdue-admin-toolbar__expired",
@@ -221,15 +221,20 @@ export default function FontdueAdminToolbar() {
221
221
  "data-testid": "reenter-preview-button"
222
222
  }, "Re-enter preview")) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("label", {
223
223
  className: "fontdue-admin-toolbar__toggle"
224
- }, /*#__PURE__*/React.createElement("input", {
224
+ }, /*#__PURE__*/React.createElement("span", {
225
+ className: "fontdue-admin-toolbar__toggle-text"
226
+ }, /*#__PURE__*/React.createElement("strong", null, "Hidden fonts"), /*#__PURE__*/React.createElement("span", null, "Show unpublished families, only to you")), /*#__PURE__*/React.createElement("input", {
225
227
  type: "checkbox",
226
228
  checked: previewing,
227
229
  disabled: busy,
228
230
  onChange: e => e.target.checked ? enterPreview() : exitPreview(),
229
231
  "data-testid": "preview-toggle"
230
- }), /*#__PURE__*/React.createElement("span", null, "Preview hidden fonts")), /*#__PURE__*/React.createElement("p", {
231
- className: "fontdue-admin-toolbar__hint"
232
- }, previewing ? 'Unpublished fonts are visible across the site — only to you.' : 'Reveal unpublished fonts everywhere, only for you.')), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
232
+ }), /*#__PURE__*/React.createElement("span", {
233
+ className: "fontdue-admin-toolbar__switch",
234
+ "aria-hidden": "true"
235
+ })), previewing && /*#__PURE__*/React.createElement("p", {
236
+ className: "fontdue-admin-toolbar__status"
237
+ }, "Visible across the whole site until you switch it off or the preview expires.")), (revalidateEndpoint || adminUrl) && /*#__PURE__*/React.createElement("div", {
233
238
  className: "fontdue-admin-toolbar__actions"
234
239
  }, revalidateEndpoint && /*#__PURE__*/React.createElement("button", {
235
240
  type: "button",
@@ -249,7 +254,7 @@ export default function FontdueAdminToolbar() {
249
254
  className: "fontdue-admin-toolbar__error"
250
255
  }, error), /*#__PURE__*/React.createElement("p", {
251
256
  className: "fontdue-admin-toolbar__meta"
252
- }, fontdueHost ? `Shown because you’re signed in to Fontdue at ${fontdueHost}.` : 'Shown because you’re signed in to Fontdue.', /*#__PURE__*/React.createElement("span", {
257
+ }, fontdueHost ? `Shown only to you – you’re signed in to Fontdue at ${fontdueHost}.` : 'Shown only to you – you’re signed in to Fontdue.', /*#__PURE__*/React.createElement("span", {
253
258
  className: "fontdue-admin-toolbar__version"
254
259
  }, "fontdue-js ", version))), /*#__PURE__*/React.createElement("button", {
255
260
  type: "button",
@@ -257,10 +262,17 @@ export default function FontdueAdminToolbar() {
257
262
  onClick: () => setOpen(v => !v),
258
263
  "aria-expanded": open,
259
264
  "data-testid": "fontdue-admin-toolbar-button"
260
- }, expired ? /*#__PURE__*/React.createElement(WarningIcon, null) : /*#__PURE__*/React.createElement("span", {
261
- className: "fontdue-admin-toolbar__dot",
265
+ }, expired ? /*#__PURE__*/React.createElement(WarningIcon, null) : /*#__PURE__*/React.createElement(FontdueMark, null), expired ? 'Preview expired' : previewing ? 'Previewing hidden fonts' : 'Preview'));
266
+ }
267
+
268
+ // The Fontdue mark: a serif F on an iris square. Brands the pill and the panel
269
+ // header so the control can name its function ("Preview") instead of the
270
+ // vendor; the full attribution lives in the panel footer.
271
+ function FontdueMark() {
272
+ return /*#__PURE__*/React.createElement("span", {
273
+ className: "fontdue-admin-toolbar__mark",
262
274
  "aria-hidden": "true"
263
- }), "Fontdue", previewing ? ' · previewing' : '', expired ? ' · preview expired' : ''));
275
+ }, "F");
264
276
  }
265
277
 
266
278
  // Small inline warning glyph (a triangle with an exclamation). Inherits the
@@ -5,13 +5,17 @@ import _NewsletterSignupQuery from "../../__generated__/NewsletterSignupQuery.gr
5
5
  import _NewsletterSignupUpdateCustomerMutation from "../../__generated__/NewsletterSignupUpdateCustomerMutation.graphql.js";
6
6
  import React, { useState, useRef, useCallback } from 'react';
7
7
  import { graphql, commitMutation, useRelayEnvironment, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
8
- import { ReCAPTCHA } from 'react-google-recaptcha';
8
+ // The script-injecting reCAPTCHA wrapper (loads Google's api.js). Importing the
9
+ // package's barebone named export instead was the FD-917 root cause; see
10
+ // ../Recaptcha for the full explanation.
11
+ import { ReCAPTCHA } from '../Recaptcha.js';
9
12
  import TextField from '../TextField/index.js';
10
13
  import Check from '../Icons/Check.js';
11
14
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
12
15
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
13
16
  import NewsletterSignupQueryNode from '../../__generated__/NewsletterSignupQuery.graphql.js';
14
17
  import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
18
+ import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
15
19
  const updateCustomerMutation = (_NewsletterSignupUpdateCustomerMutation.hash && _NewsletterSignupUpdateCustomerMutation.hash !== "769087891b6f263122bbb630b3f2ca6c" && console.error("The definition of 'NewsletterSignupUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _NewsletterSignupUpdateCustomerMutation);
16
20
  export async function loadNewsletterSignupQuery(options) {
17
21
  return loadSerializableQuery(NewsletterSignupQueryNode, {}, options);
@@ -37,10 +41,12 @@ function NewsletterSignupComponent(_ref) {
37
41
  const [recaptchaToken, setRecaptchaToken] = useState(null);
38
42
  const [pendingSubmit, setPendingSubmit] = useState(false);
39
43
  const recaptchaRef = useRef(null);
44
+ const recaptchaTimeout = useRecaptchaTimeout();
40
45
  const environment = useRelayEnvironment();
41
46
  const recaptchaEnabled = ((_data$viewer = data.viewer) === null || _data$viewer === void 0 ? void 0 : (_data$viewer$settings = _data$viewer.settings) === null || _data$viewer$settings === void 0 ? void 0 : _data$viewer$settings.recaptchaEnabled) ?? false;
42
47
  const recaptchaSiteKey = (_data$viewer2 = data.viewer) === null || _data$viewer2 === void 0 ? void 0 : (_data$viewer2$setting = _data$viewer2.settings) === null || _data$viewer2$setting === void 0 ? void 0 : _data$viewer2$setting.recaptchaSiteKey;
43
48
  const submitForm = useCallback(token => {
49
+ recaptchaTimeout.clear();
44
50
  setSubmitting(true);
45
51
  setPendingSubmit(false);
46
52
  setError(null);
@@ -80,7 +86,7 @@ function NewsletterSignupComponent(_ref) {
80
86
  setRecaptchaToken(null);
81
87
  }
82
88
  });
83
- }, [environment, name, email, newsletterOptIn]);
89
+ }, [environment, name, email, newsletterOptIn, recaptchaTimeout]);
84
90
  const handleRecaptchaChange = useCallback(token => {
85
91
  setRecaptchaToken(token);
86
92
  // If we were waiting for a token to submit, do it now
@@ -91,15 +97,36 @@ function NewsletterSignupComponent(_ref) {
91
97
  const handleRecaptchaExpired = useCallback(() => {
92
98
  setRecaptchaToken(null);
93
99
  }, []);
100
+
101
+ // Recover cleanly when the reCAPTCHA widget can't produce a token, so the
102
+ // button doesn't sit on “Submitting…” forever if the widget fails to load.
103
+ const abortRecaptcha = useCallback(() => {
104
+ var _recaptchaRef$current3;
105
+ recaptchaTimeout.clear();
106
+ setPendingSubmit(false);
107
+ setRecaptchaToken(null);
108
+ (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.reset();
109
+ setError(RECAPTCHA_UNAVAILABLE_MESSAGE);
110
+ }, [recaptchaTimeout]);
111
+ const handleRecaptchaErrored = useCallback(() => {
112
+ if (pendingSubmit) abortRecaptcha();
113
+ }, [pendingSubmit, abortRecaptcha]);
94
114
  const handleSubmit = e => {
95
115
  e.preventDefault();
96
116
 
97
117
  // If reCAPTCHA is enabled but no token, execute it and wait for callback
98
118
  if (recaptchaEnabled && recaptchaSiteKey && !recaptchaToken) {
99
- var _recaptchaRef$current3;
119
+ var _recaptchaRef$current4;
100
120
  setPendingSubmit(true);
101
121
  setError(null);
102
- (_recaptchaRef$current3 = recaptchaRef.current) === null || _recaptchaRef$current3 === void 0 ? void 0 : _recaptchaRef$current3.execute();
122
+ // Safety net: give up if no token arrives and the reCAPTCHA script never
123
+ // loaded (a challenge in progress keeps `grecaptcha` defined, so we don't
124
+ // cancel on a user who's mid-challenge).
125
+ recaptchaTimeout.start(() => {
126
+ if (isRecaptchaScriptLoaded()) return;
127
+ abortRecaptcha();
128
+ });
129
+ (_recaptchaRef$current4 = recaptchaRef.current) === null || _recaptchaRef$current4 === void 0 ? void 0 : _recaptchaRef$current4.execute();
103
130
  return;
104
131
  }
105
132
  submitForm(recaptchaToken);
@@ -167,7 +194,8 @@ function NewsletterSignupComponent(_ref) {
167
194
  sitekey: recaptchaSiteKey,
168
195
  size: "invisible",
169
196
  onChange: handleRecaptchaChange,
170
- onExpired: handleRecaptchaExpired
197
+ onExpired: handleRecaptchaExpired,
198
+ onErrored: handleRecaptchaErrored
171
199
  }), /*#__PURE__*/React.createElement("div", {
172
200
  className: "newsletter-signup__section"
173
201
  }, /*#__PURE__*/React.createElement("button", {
@@ -29,9 +29,13 @@ export default function NodePasswordForm(props) {
29
29
  // Tell the storefront's unlock route (fontdue-js/next/unlock) about a
30
30
  // successful unlock so it can enable Next's draft-mode bypass — without it, a
31
31
  // statically-cached font page would keep serving this visitor the password
32
- // form after they unlocked (the full-route cache is visitor-blind). Always
33
- // resolves: the reload must happen even if the endpoint is absent or errors,
34
- // because on per-request-rendered frameworks the cookie alone unlocks.
32
+ // form after they unlocked (the full-route cache is visitor-blind). The
33
+ // current pathname rides along so the route can scope the bypass cookie to
34
+ // the page being unlocked instead of the whole site — otherwise every page
35
+ // this visitor touches would render dynamically (and slowly) for the rest of
36
+ // their session. Always resolves: the reload must happen even if the endpoint
37
+ // is absent or errors, because on per-request-rendered frameworks the cookie
38
+ // alone unlocks.
35
39
  async function notifyUnlockEndpoint(endpoint, token) {
36
40
  if (!endpoint || !token) return;
37
41
  try {
@@ -41,7 +45,8 @@ async function notifyUnlockEndpoint(endpoint, token) {
41
45
  'content-type': 'application/json'
42
46
  },
43
47
  body: JSON.stringify({
44
- token
48
+ token,
49
+ path: window.location.pathname
45
50
  })
46
51
  });
47
52
  } catch {
@@ -0,0 +1,3 @@
1
+ import { ReCAPTCHA as ReCAPTCHAClass } from 'react-google-recaptcha';
2
+ export declare const ReCAPTCHA: typeof ReCAPTCHAClass;
3
+ export type ReCAPTCHA = ReCAPTCHAClass;
@@ -0,0 +1,38 @@
1
+ // Correct re-export of react-google-recaptcha (FD-917).
2
+ //
3
+ // The package exposes two very different things:
4
+ // - the DEFAULT export is the script-injecting wrapper (built with
5
+ // react-async-script): it loads Google's `api.js`, populates
6
+ // `window.grecaptcha`, then renders the widget. This is what you almost
7
+ // always want.
8
+ // - the NAMED `ReCAPTCHA` export is the *barebone* widget. It never loads the
9
+ // script; it expects you to supply the `grecaptcha` object yourself. On its
10
+ // own it renders an empty <div> and `execute()` is a permanent no-op.
11
+ //
12
+ // Every fontdue-js form imported the named (barebone) export, so on
13
+ // reCAPTCHA-enabled tenants the invisible widget silently never loaded — the
14
+ // checkout button hung on "Submitting…" forever with no token, no request, no
15
+ // error. This module re-exports the wrapper under the same name so callers get
16
+ // the working component.
17
+ //
18
+ // Interop caveat — why we normalise the default binding instead of re-exporting
19
+ // it directly: react-google-recaptcha is CJS. Bundlers that honour the
20
+ // `__esModule` marker (webpack/Next, and Vite's *browser* optimizer, which
21
+ // `fontdue-js/vite` pre-bundles this package through) unwrap `import Default
22
+ // from 'react-google-recaptcha'` straight to the wrapper. But Node's strict
23
+ // ESM↔CJS interop — which Vite *dev SSR* uses for this externalized dep — models
24
+ // the same default import as the whole module *namespace*
25
+ // (`{ __esModule, default, ReCAPTCHA }`), not the wrapper. Re-exporting that
26
+ // object made `<ReCAPTCHA>` an object at SSR time, so React threw "Element type
27
+ // is invalid … got: object" and the reCAPTCHA form islands failed to
28
+ // server-render (they fell back to client rendering with a console error).
29
+ // Applying the standard `obj.__esModule ? obj.default : obj` normalisation here
30
+ // resolves the wrapper on every path — webpack, Vite browser, and Node/Vite SSR.
31
+ // react-async-script forwards refs to the inner widget, so `.execute()` /
32
+ // `.reset()` / `.getValue()` still work through a ref typed as the class.
33
+ import ReCAPTCHADefault from 'react-google-recaptcha';
34
+
35
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
36
+ const mod = ReCAPTCHADefault;
37
+ const Wrapper = mod && mod.__esModule && mod.default ? mod.default : mod;
38
+ export const ReCAPTCHA = Wrapper;