fontdue-js 3.4.1 → 3.5.1

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 (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +9 -1
  4. package/dist/__generated__/PrecartAddToCartMutation.graphql.js +1 -1
  5. package/dist/__generated__/StoreModalProductSummaryAddToCartMutation.graphql.d.ts +9 -1
  6. package/dist/__generated__/StoreModalProductSummaryAddToCartMutation.graphql.js +1 -1
  7. package/dist/__tests__/createFontdueFetch.test.js +16 -0
  8. package/dist/__tests__/loadSerializableQuery.test.js +73 -0
  9. package/dist/__tests__/networkFetch.test.js +58 -0
  10. package/dist/__tests__/networkStatus.test.js +105 -0
  11. package/dist/__tests__/previewServer.test.js +1 -0
  12. package/dist/__tests__/providerBoundaries.test.js +56 -0
  13. package/dist/__tests__/useSerializablePreloadedQuery.test.js +66 -0
  14. package/dist/components/BuyButton/index.js +5 -5
  15. package/dist/components/Cart/orderTracking.d.ts +22 -5
  16. package/dist/components/Cart/orderTracking.js +32 -13
  17. package/dist/components/CharacterViewer/index.js +5 -5
  18. package/dist/components/CustomerLoginForm/index.js +8 -2
  19. package/dist/components/FeatureTester/FeatureTesters.js +4 -4
  20. package/dist/components/FeatureTester/index.js +2 -2
  21. package/dist/components/FontdueContextProvider/index.d.ts +3 -0
  22. package/dist/components/FontdueContextProvider/index.js +40 -5
  23. package/dist/components/NewsletterSignup/index.js +2 -2
  24. package/dist/components/Precart/index.js +3 -1
  25. package/dist/components/StoreModalProductSummary/index.js +3 -1
  26. package/dist/components/TestFontsForm/index.js +2 -2
  27. package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
  28. package/dist/components/TypeTesters/index.js +5 -5
  29. package/dist/relay/environment.d.ts +16 -2
  30. package/dist/relay/environment.js +78 -10
  31. package/dist/relay/loadSerializableQuery.js +12 -0
  32. package/dist/relay/useSerializablePreloadedQuery.js +10 -6
  33. package/dist/server/index.js +6 -1
  34. package/dist/version.d.ts +2 -0
  35. package/dist/version.js +11 -0
  36. package/package.json +1 -1
@@ -5,27 +5,46 @@ function readCookie(name) {
5
5
  const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
6
6
  return match ? decodeURIComponent(match[1]) : undefined;
7
7
  }
8
-
9
8
  /**
10
- * Stores the buyer's analytics context (cookie consent, Meta browser IDs,
11
- * checkout page URL) on the current order. The server emits the purchase
12
- * event from a Stripe webhook outside the browser — so this is how that
13
- * event respects the consent banner and carries attribution.
9
+ * The buyer's analytics context as the order mutations take it: cookie
10
+ * consent, the anonymous ID, Meta's browser IDs (`_fbp`/`_fbc`) and the page.
11
+ * Sent with add-to-cart (`createOrderItems`) and cart/checkout open
12
+ * (`updateOrderTracking`) alike, so the server captures the same identifiers
13
+ * on every event it emits for the order — "Product Added", "Checkout Started"
14
+ * (which the server emits when the buyer submits their contact details, not
15
+ * from these calls) and the purchase, which fires from a Stripe webhook with
16
+ * no browser at all.
14
17
  *
15
- * Fire-and-forget: tracking must never break checkout.
18
+ * Returns undefined outside a browser or if anything throws: tracking must
19
+ * never break the cart.
20
+ */
21
+ export function orderTrackingInput() {
22
+ try {
23
+ if (typeof window === 'undefined') return undefined;
24
+ return {
25
+ analyticsConsent: hasConsent('analytics'),
26
+ anonymousId: getClientAnonymousId(),
27
+ fbp: readCookie('_fbp'),
28
+ fbc: readCookie('_fbc'),
29
+ url: window.location.href
30
+ };
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Stores the buyer's analytics context on the current order as the cart or
38
+ * checkout opens. Fire-and-forget: tracking must never break checkout.
16
39
  */
17
40
  export function sendOrderTracking(environment) {
41
+ const input = orderTrackingInput();
42
+ if (!input) return;
18
43
  try {
19
44
  commitMutation(environment, {
20
45
  mutation: (_orderTrackingUpdateOrderTrackingMutation.hash && _orderTrackingUpdateOrderTrackingMutation.hash !== "d59a127a7f140424f507ae549731bac7" && console.error("The definition of 'orderTrackingUpdateOrderTrackingMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _orderTrackingUpdateOrderTrackingMutation),
21
46
  variables: {
22
- input: {
23
- analyticsConsent: hasConsent('analytics'),
24
- anonymousId: getClientAnonymousId(),
25
- fbp: readCookie('_fbp'),
26
- fbc: readCookie('_fbc'),
27
- url: window.location.href
28
- }
47
+ input
29
48
  },
30
49
  onCompleted: () => undefined,
31
50
  onError: () => undefined
@@ -20,7 +20,7 @@ import StyleSelect from './StyleSelect.js';
20
20
  import Checkbox from '../Checkbox/index.js';
21
21
  import useFeaturesData from '../TypeTester/useFeaturesData.js';
22
22
  import CharacterViewerStyleRefetchQueryNode from '../../__generated__/CharacterViewerStyleRefetchQuery.graphql.js';
23
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
23
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
24
24
  import ConfigContext from '../ConfigContext.js';
25
25
  import { unicodeNamesUrl } from '../../data/unicodeNamesUrl.js';
26
26
  import { compareGlyphs, flattenCharacterList } from './glyphMatch.js';
@@ -498,17 +498,17 @@ export default function CharacterViewer(props) {
498
498
  collectionId,
499
499
  ...rest
500
500
  } = props;
501
- inner = /*#__PURE__*/React.createElement(CharacterViewerIdQueryRenderer, _extends({
501
+ inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(CharacterViewerIdQueryRenderer, _extends({
502
502
  collectionId: collectionId
503
- }, rest));
503
+ }, rest)));
504
504
  } else if ('collectionSlug' in props && props.collectionSlug) {
505
505
  const {
506
506
  collectionSlug,
507
507
  ...rest
508
508
  } = props;
509
- inner = /*#__PURE__*/React.createElement(CharacterViewerSlugQueryRenderer, _extends({
509
+ inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(CharacterViewerSlugQueryRenderer, _extends({
510
510
  collectionSlug: collectionSlug
511
- }, rest));
511
+ }, rest)));
512
512
  } else {
513
513
  throw new Error('CharacterViewer expected one of preloadedQuery, collectionId, or collectionSlug');
514
514
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import _CustomerLoginFormQuery from "../../__generated__/CustomerLoginFormQuery.graphql.js";
4
4
  import _CustomerLoginFormLoginMutation from "../../__generated__/CustomerLoginFormLoginMutation.graphql.js";
5
- import React, { useState } from 'react';
5
+ import React, { Suspense, useState } from 'react';
6
6
  import { commitMutation, graphql, useLazyLoadQuery, useRelayEnvironment } from 'react-relay';
7
7
  import TextField from '../TextField/index.js';
8
8
  const loginMutation = (_CustomerLoginFormLoginMutation.hash && _CustomerLoginFormLoginMutation.hash !== "975b639fac1e0d88e0f0c9c55acd8b9c" && console.error("The definition of 'CustomerLoginFormLoginMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _CustomerLoginFormLoginMutation);
@@ -51,7 +51,13 @@ const CustomerLoginForm = _ref => {
51
51
  className: "login-form"
52
52
  }, error && /*#__PURE__*/React.createElement("div", {
53
53
  className: "login-form__errors"
54
- }, error), submitted ? /*#__PURE__*/React.createElement(SubmittedMessage, null) : /*#__PURE__*/React.createElement("form", {
54
+ }, error), submitted ?
55
+ /*#__PURE__*/
56
+ // The label fetch suspends; resolve it here rather than at whatever
57
+ // boundary the host app has above the form.
58
+ React.createElement(Suspense, {
59
+ fallback: null
60
+ }, /*#__PURE__*/React.createElement(SubmittedMessage, null)) : /*#__PURE__*/React.createElement("form", {
55
61
  className: "login-form__form",
56
62
  onSubmit: handleSubmit
57
63
  }, /*#__PURE__*/React.createElement("div", {
@@ -7,7 +7,7 @@ import _FeatureTesters_collection from "../../__generated__/FeatureTesters_colle
7
7
  import React from 'react';
8
8
  import { graphql, useFragment, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
9
9
  import FeatureTesterCard from './FeatureTesterCard.js';
10
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
10
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
11
11
  import FeatureTestersIdQueryNode from '../../__generated__/FeatureTestersIdQuery.graphql.js';
12
12
  import FeatureTestersSlugQueryNode from '../../__generated__/FeatureTestersSlugQuery.graphql.js';
13
13
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
@@ -148,9 +148,9 @@ export default function FeatureTesters(_ref7) {
148
148
  config: config
149
149
  }, 'preloadedQuery' in props && props.preloadedQuery ? /*#__PURE__*/React.createElement(FeatureTestersPreloadedRenderer, _extends({
150
150
  preloadedQuery: props.preloadedQuery
151
- }, options)) : 'collectionId' in props && props.collectionId ? /*#__PURE__*/React.createElement(ById, _extends({
151
+ }, options)) : 'collectionId' in props && props.collectionId ? /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(ById, _extends({
152
152
  collectionId: props.collectionId
153
- }, options)) : /*#__PURE__*/React.createElement(BySlug, _extends({
153
+ }, options))) : /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(BySlug, _extends({
154
154
  collectionSlug: props.collectionSlug
155
- }, options)));
155
+ }, options))));
156
156
  }
@@ -5,7 +5,7 @@ import _FeatureTesterStandaloneQuery from "../../__generated__/FeatureTesterStan
5
5
  import React from 'react';
6
6
  import { graphql, useLazyLoadQuery, usePreloadedQuery } from 'react-relay';
7
7
  import FeatureTesterCard from './FeatureTesterCard.js';
8
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
8
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
9
9
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
10
10
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
11
11
  import FeatureTesterStandaloneQueryNode from '../../__generated__/FeatureTesterStandaloneQuery.graphql.js';
@@ -62,5 +62,5 @@ function FeatureTesterLazyRenderer(_ref3) {
62
62
  export default function FeatureTester(props) {
63
63
  return /*#__PURE__*/React.createElement(EnsureFontdueContext, {
64
64
  config: props.config
65
- }, 'preloadedQuery' in props && props.preloadedQuery ? /*#__PURE__*/React.createElement(FeatureTesterPreloadedRenderer, props) : /*#__PURE__*/React.createElement(FeatureTesterLazyRenderer, props));
65
+ }, 'preloadedQuery' in props && props.preloadedQuery ? /*#__PURE__*/React.createElement(FeatureTesterPreloadedRenderer, props) : /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(FeatureTesterLazyRenderer, props)));
66
66
  }
@@ -7,6 +7,9 @@ export declare function EnsureFontdueContext({ children, config, }: {
7
7
  children: React.ReactNode;
8
8
  config?: Config;
9
9
  }): React.JSX.Element;
10
+ export declare function LazyQueryBoundary({ children }: {
11
+ children: React.ReactNode;
12
+ }): React.JSX.Element;
10
13
  export interface FontdueContextProvider_props {
11
14
  children?: React.ReactNode;
12
15
  url?: string;
@@ -53,6 +53,14 @@ function ConfigOverride(_ref) {
53
53
  // typeTester: { selectable: true } }} />` works without any provider. Inside an
54
54
  // existing context, it deep-merges onto the inherited config — the component's
55
55
  // keys win, everything else is inherited from the outer `<FontdueProvider>`.
56
+ //
57
+ // Deliberately no Suspense boundary here either: one around every component
58
+ // would put a dehydrated boundary in the server HTML of each preloaded
59
+ // component, and React hydrates those lazily — measured on a Next 16 / React
60
+ // 19.2 page, the preloaded BuyButton, TypeTesters and CharacterViewer were
61
+ // still unhydrated a minute after load (a click still works, since React
62
+ // hydrates the target on interaction, but nothing else does). Only the lazy
63
+ // renderers need a boundary; see LazyQueryBoundary.
56
64
  export function EnsureFontdueContext(_ref2) {
57
65
  let {
58
66
  children,
@@ -68,6 +76,27 @@ export function EnsureFontdueContext(_ref2) {
68
76
  config: config
69
77
  }, children);
70
78
  }
79
+
80
+ // The Suspense boundary for a component's *lazy* renderer — the branch taken
81
+ // when it's given an id or slug rather than a preloaded query, so it fetches
82
+ // its own data with useLazyLoadQuery (see the lazy-vs-preloaded guide). That
83
+ // suspension has to resolve here, next to the component, not at whatever
84
+ // boundary the host app has above it: the provider used to supply one around
85
+ // the whole app, which blanked everything beneath it while any one component
86
+ // loaded — and put every Next page inside a Suspense boundary, so
87
+ // `notFound()`, `redirect()` and thrown errors were caught in it and the
88
+ // response streamed as 200 (see FontdueContextProvider below). Preloaded
89
+ // renderers resolve synchronously and get no boundary, so their server HTML
90
+ // hydrates with the page as before. A null fallback keeps server HTML and the
91
+ // first client render identical.
92
+ export function LazyQueryBoundary(_ref3) {
93
+ let {
94
+ children
95
+ } = _ref3;
96
+ return /*#__PURE__*/React.createElement(Suspense, {
97
+ fallback: null
98
+ }, children);
99
+ }
71
100
  const IS_SERVER = typeof window === typeof undefined;
72
101
 
73
102
  // === Multi-island state-sharing contract ===
@@ -112,7 +141,15 @@ function useSharedStore(providedStore, config) {
112
141
  // `*Preloaded` self-wrapping components use this so they can stand alone
113
142
  // without claiming the page-level aux UI slot. Pages that need aux UI
114
143
  // (theme/test-mode/consent/tracking) wrap with `FontdueProvider` instead.
115
- export default function FontdueContextProvider(_ref3) {
144
+ //
145
+ // Deliberately NO Suspense (or error) boundary around `children`. Mounted in a
146
+ // Next root layout, a boundary here sits above every page: React SSR then
147
+ // catches a page's `notFound()`, `redirect()` or thrown error inside it, emits
148
+ // the fallback, and streams the shell as 200 — soft 404s site-wide, redirects
149
+ // with no Location header, error.tsx never rendering (FD-1183 / FD-1074).
150
+ // Components that can suspend carry their own boundary via
151
+ // `EnsureFontdueContext`; the provider's aux UI has one in FontdueProvider.
152
+ export default function FontdueContextProvider(_ref4) {
116
153
  let {
117
154
  children,
118
155
  url,
@@ -120,7 +157,7 @@ export default function FontdueContextProvider(_ref3) {
120
157
  config,
121
158
  components,
122
159
  store
123
- } = _ref3;
160
+ } = _ref4;
124
161
  const environment = useCurrentEnvironment({
125
162
  url,
126
163
  stripeIntegration
@@ -136,8 +173,6 @@ export default function FontdueContextProvider(_ref3) {
136
173
  environment: environment
137
174
  }, /*#__PURE__*/React.createElement(Provider, {
138
175
  store: sharedStore
139
- }, /*#__PURE__*/React.createElement(Suspense, {
140
- fallback: null
141
176
  }, /*#__PURE__*/React.createElement(RawConfigContext.Provider, {
142
177
  value: config
143
178
  }, /*#__PURE__*/React.createElement(ConfigContext.Provider, {
@@ -146,5 +181,5 @@ export default function FontdueContextProvider(_ref3) {
146
181
  value: url ?? fontdueBaseUrl() ?? ''
147
182
  }, /*#__PURE__*/React.createElement(ComponentsContext.Provider, {
148
183
  value: components ?? {}
149
- }, /*#__PURE__*/React.createElement(TypeTesterFamiliesProvider, null, children)))))))));
184
+ }, /*#__PURE__*/React.createElement(TypeTesterFamiliesProvider, null, children))))))));
150
185
  }
@@ -14,7 +14,7 @@ import Check from '../Icons/Check.js';
14
14
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
15
15
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
16
16
  import NewsletterSignupQueryNode from '../../__generated__/NewsletterSignupQuery.graphql.js';
17
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
17
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
18
18
  import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
19
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);
20
20
  export async function loadNewsletterSignupQuery(options) {
@@ -238,5 +238,5 @@ export default function NewsletterSignup(_ref4) {
238
238
  config: config
239
239
  }, props.preloadedQuery ? /*#__PURE__*/React.createElement(NewsletterSignupPreloadedQueryRenderer, _extends({}, props, {
240
240
  preloadedQuery: props.preloadedQuery
241
- })) : /*#__PURE__*/React.createElement(NewsletterSignupLazyQueryRenderer, props));
241
+ })) : /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(NewsletterSignupLazyQueryRenderer, props)));
242
242
  }
@@ -12,6 +12,7 @@ import License from './License.js';
12
12
  import { Price } from '../Price/index.js';
13
13
  import { pluralize } from '../../utils.js';
14
14
  import ComponentsContext from '../ComponentsContext.js';
15
+ import { orderTrackingInput } from '../Cart/orderTracking.js';
15
16
  function skuName(sku) {
16
17
  if (sku.product && 'name' in sku.product) return sku.product.name;
17
18
  return null;
@@ -153,7 +154,8 @@ function Precart(_ref3) {
153
154
  const variables = {
154
155
  input: {
155
156
  skuIds: selectedItemsArray(),
156
- licenseSelections: licenseSelectionsArray()
157
+ licenseSelections: licenseSelectionsArray(),
158
+ tracking: orderTrackingInput()
157
159
  }
158
160
  };
159
161
  const mutation = (_PrecartAddToCartMutation.hash && _PrecartAddToCartMutation.hash !== "c5ead46ecc07099bee68d4f4f1ecb5bf" && console.error("The definition of 'PrecartAddToCartMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _PrecartAddToCartMutation);
@@ -11,6 +11,7 @@ import { pluralize } from '../../utils.js';
11
11
  import { useLicenseAndOrderVariables } from '../../hooks/useLicenseAndOrderVariables.js';
12
12
  import { useRefetchOnLicenseChanges } from '../../hooks/useRefetchOnLicenseChanges.js';
13
13
  import ConfigContext from '../ConfigContext.js';
14
+ import { orderTrackingInput } from '../Cart/orderTracking.js';
14
15
  const addToCartMutation = (_StoreModalProductSummaryAddToCartMutation.hash && _StoreModalProductSummaryAddToCartMutation.hash !== "91ea762e3842af2919da3a4c710a48cc" && console.error("The definition of 'StoreModalProductSummaryAddToCartMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _StoreModalProductSummaryAddToCartMutation);
15
16
  const clearCartMutation = (_StoreModalProductSummaryClearCartMutation.hash && _StoreModalProductSummaryClearCartMutation.hash !== "b5c37f74432030a297c1bcf2be63b6f7" && console.error("The definition of 'StoreModalProductSummaryClearCartMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _StoreModalProductSummaryClearCartMutation);
16
17
  const countStyles = viewer => {
@@ -105,7 +106,8 @@ const StoreModalProductSummary = _ref => {
105
106
  });
106
107
  }, []),
107
108
  orderVariableSelections,
108
- licenseeIsBillingIdentity
109
+ licenseeIsBillingIdentity,
110
+ tracking: orderTrackingInput()
109
111
  }
110
112
  };
111
113
  commitAddToCart({
@@ -15,7 +15,7 @@ import TestFontsFormQueryNode from '../../__generated__/TestFontsForm_Query.grap
15
15
  import Checkbox from '../Checkbox/index.js';
16
16
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
17
17
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
18
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
18
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
19
19
  import { useRecaptchaTimeout, isRecaptchaScriptLoaded, RECAPTCHA_UNAVAILABLE_MESSAGE } from '../../hooks/useRecaptchaTimeout.js';
20
20
  const updateCustomerMutation = (_TestFontsFormUpdateCustomerMutation.hash && _TestFontsFormUpdateCustomerMutation.hash !== "ba56958399f0893bd667ff02c33a6975" && console.error("The definition of 'TestFontsFormUpdateCustomerMutation' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TestFontsFormUpdateCustomerMutation);
21
21
  const TestFontsDownloading = _ref => {
@@ -258,5 +258,5 @@ export default function TestFontsForm(_ref5) {
258
258
  config: config
259
259
  }, props.preloadedQuery ? /*#__PURE__*/React.createElement(TestFontsFormPreloadedQueryRenderer, _extends({}, props, {
260
260
  preloadedQuery: props.preloadedQuery
261
- })) : /*#__PURE__*/React.createElement(TestFontsFormLazyQueryRenderer, props));
261
+ })) : /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(TestFontsFormLazyQueryRenderer, props)));
262
262
  }
@@ -12,7 +12,7 @@ import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
12
12
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
13
13
  import TypeTesterStandaloneQueryNode from '../../__generated__/TypeTesterStandaloneQuery.graphql.js';
14
14
  import ConfigContext from '../ConfigContext.js';
15
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
15
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
16
16
  import { useLicenseAndOrderVariables } from '../../hooks/useLicenseAndOrderVariables.js';
17
17
  const changedStylesQuery = (_TypeTesterStandaloneChangedStylesQuery.hash && _TypeTesterStandaloneChangedStylesQuery.hash !== "98374227c6b2c3eceab5b48138e1a662" && console.error("The definition of 'TypeTesterStandaloneChangedStylesQuery' appears to have changed. Run `relay-compiler` to update the generated files to receive the expected data."), _TypeTesterStandaloneChangedStylesQuery);
18
18
  function TypeTesterStandaloneComponent(_ref) {
@@ -144,5 +144,5 @@ function TypeTesterStandaloneLazyQueryRenderer(_ref4) {
144
144
  export default function TypeTesterStandalone(props) {
145
145
  return /*#__PURE__*/React.createElement(EnsureFontdueContext, {
146
146
  config: props.config
147
- }, 'preloadedQuery' in props && props.preloadedQuery ? /*#__PURE__*/React.createElement(TypeTesterStandalonePreloadedQueryRenderer, props) : /*#__PURE__*/React.createElement(TypeTesterStandaloneLazyQueryRenderer, props));
147
+ }, 'preloadedQuery' in props && props.preloadedQuery ? /*#__PURE__*/React.createElement(TypeTesterStandalonePreloadedQueryRenderer, props) : /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(TypeTesterStandaloneLazyQueryRenderer, props)));
148
148
  }
@@ -23,7 +23,7 @@ import { normalizeFeaturesProp } from '../TypeTester/types.js';
23
23
  import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
24
24
  import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
25
25
  import { useLicenseAndOrderVariables } from '../../hooks/useLicenseAndOrderVariables.js';
26
- import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
26
+ import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
27
27
  const findPriceBarCollection = (collection, familyId) => {
28
28
  if (collection.id === familyId) return collection;
29
29
  if (!collection.families) return null;
@@ -306,13 +306,13 @@ export default function TypeTesters(_ref8) {
306
306
  preloadedQuery: preloadedQuery
307
307
  }, rest));
308
308
  } else if (collectionId) {
309
- inner = /*#__PURE__*/React.createElement(TypeTestersIDQueryRenderer, _extends({
309
+ inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(TypeTestersIDQueryRenderer, _extends({
310
310
  collectionId: collectionId
311
- }, rest));
311
+ }, rest)));
312
312
  } else if (collectionSlug) {
313
- inner = /*#__PURE__*/React.createElement(TypeTestersSlugQueryRenderer, _extends({
313
+ inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(TypeTestersSlugQueryRenderer, _extends({
314
314
  collectionSlug: collectionSlug
315
- }, rest));
315
+ }, rest)));
316
316
  }
317
317
  if (!inner) return null;
318
318
  return /*#__PURE__*/React.createElement(EnsureFontdueContext, {
@@ -1,5 +1,20 @@
1
1
  import { Environment, RequestParameters, QueryResponseCache, Variables, GraphQLResponse } from 'relay-runtime';
2
- export declare const version: string;
2
+ import { version } from '../version.js';
3
+ export { version };
4
+ /**
5
+ * A GraphQL request that came back with a non-2xx status, or a 2xx whose body
6
+ * isn't a GraphQL response at all. Thrown rather than returned so a failed
7
+ * request can never masquerade as data: on the server that payload would be
8
+ * serialized into the prerendered page, and every visitor would hydrate into
9
+ * Relay's "No data returned for operation …" until the page was regenerated.
10
+ */
11
+ export declare class FontdueResponseError extends Error {
12
+ /** The HTTP status the Fontdue endpoint answered with. */
13
+ status: number;
14
+ /** The GraphQL operation name, e.g. `FontdueProviderQuery`. */
15
+ operation: string;
16
+ constructor(message: string, status: number, operation: string);
17
+ }
3
18
  export declare function fontdueBaseUrl(): string | undefined;
4
19
  export declare function createNetworkFetch(options?: CreateRelayEnvironmentOptions): (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
5
20
  export declare const networkFetch: (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
@@ -16,4 +31,3 @@ interface CreateRelayEnvironmentOptions {
16
31
  }
17
32
  export declare function createEnvironment(options: CreateRelayEnvironmentOptions): Environment;
18
33
  export declare function useCurrentEnvironment(options: CreateRelayEnvironmentOptions): Environment;
19
- export {};
@@ -3,12 +3,11 @@ import { handlePossibleCorsError } from '../corsError.js';
3
3
  import { resolveFontdueServerConfig } from './serverConfig.js';
4
4
  import { PREVIEW_HEADER, hasPreviewMarkerCookie } from '../preview/constants.js';
5
5
  import { NODE_ACCESS_HEADER } from '../nodeAccess.js';
6
+ import { CLIENT_VERSION_HEADER, version } from '../version.js';
6
7
 
7
- // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
8
- // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version.
9
- // Exported so UI (the admin toolbar) can surface it without re-reading the
10
- // build-time global in a 'use client' module.
11
- export const version = "3.4.1";
8
+ // Re-exported so UI (the admin toolbar) can surface the package version
9
+ // without re-reading the build-time global in a 'use client' module.
10
+ export { version };
12
11
  const IS_SERVER = typeof window === typeof undefined;
13
12
 
14
13
  // Opt server fetches into Next's data cache only in production; dev stays
@@ -48,6 +47,54 @@ const NEXT_PUBLIC_STRIPE = typeof process !== 'undefined' && process.env ? proce
48
47
  const FONTDUE_URL = readEnv('FONTDUE_URL') ?? readEnv('NEXT_PUBLIC_FONTDUE_URL') ?? NEXT_PUBLIC_URL;
49
48
  const STRIPE_INTEGRATION = readEnv('FONTDUE_STRIPE_INTEGRATION') ?? readEnv('NEXT_PUBLIC_FONTDUE_STRIPE_INTEGRATION') ?? NEXT_PUBLIC_STRIPE;
50
49
  const CACHE_TTL = 10 * 1000; // 10 seconds, to resolve preloaded results
50
+ const RETRY_DELAY_MS = 1000;
51
+
52
+ /**
53
+ * A GraphQL request that came back with a non-2xx status, or a 2xx whose body
54
+ * isn't a GraphQL response at all. Thrown rather than returned so a failed
55
+ * request can never masquerade as data: on the server that payload would be
56
+ * serialized into the prerendered page, and every visitor would hydrate into
57
+ * Relay's "No data returned for operation …" until the page was regenerated.
58
+ */
59
+ export class FontdueResponseError extends Error {
60
+ /** The HTTP status the Fontdue endpoint answered with. */
61
+
62
+ /** The GraphQL operation name, e.g. `FontdueProviderQuery`. */
63
+
64
+ constructor(message, status, operation) {
65
+ super(message);
66
+ this.name = 'FontdueResponseError';
67
+ this.status = status;
68
+ this.operation = operation;
69
+ }
70
+ }
71
+
72
+ // Transient origin failures worth a second try: a gateway or overload error
73
+ // from Cloudflare or Render (5xx), rate limiting, a request timeout.
74
+ function isRetryableStatus(status) {
75
+ return status >= 500 || status === 429 || status === 408;
76
+ }
77
+
78
+ // The GraphQL error message a failed response carries, if any — Fontdue
79
+ // answers an unknown site with `{"errors":[{"message":"Site not found"}]}`.
80
+ async function errorMessageOf(resp) {
81
+ try {
82
+ var _body$errors, _body$errors$;
83
+ const body = await resp.json();
84
+ const message = body === null || body === void 0 ? void 0 : (_body$errors = body.errors) === null || _body$errors === void 0 ? void 0 : (_body$errors$ = _body$errors[0]) === null || _body$errors$ === void 0 ? void 0 : _body$errors$.message;
85
+ return typeof message === 'string' ? message : undefined;
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ // A body with `data` (present or null) or an `errors` list. Anything else — an
92
+ // empty object from a proxy, a JSON-shaped error page — is not a payload Relay
93
+ // can act on and must fail the request instead.
94
+ function isGraphQLResponse(json) {
95
+ if (typeof json !== 'object' || json === null) return false;
96
+ return 'data' in json || Array.isArray(json.errors);
97
+ }
51
98
 
52
99
  // The configured Fontdue base URL resolved for the current runtime, or
53
100
  // undefined when it can't be determined (e.g. multi-tenant, where fetches go to
@@ -78,7 +125,7 @@ export function createNetworkFetch(options) {
78
125
  Accept: 'application/json',
79
126
  'Content-Type': 'application/json',
80
127
  'fontdue-stripe-integration': (options === null || options === void 0 ? void 0 : options.stripeIntegration) ?? STRIPE_INTEGRATION ?? 'dynamic',
81
- 'fontdue-client-version': version
128
+ [CLIENT_VERSION_HEADER]: version
82
129
  };
83
130
 
84
131
  // Whether this request is an admin *preview*. On the server that's a
@@ -130,14 +177,34 @@ export function createNetworkFetch(options) {
130
177
  for (let attempt = 0; attempt <= 2; attempt++) {
131
178
  try {
132
179
  const resp = await fetch(url + `?queryName=${request.name}`, init);
180
+
181
+ // A non-2xx response is a failed request, never data. Returning its
182
+ // body used to let a 5xx that happened to parse as JSON (Cloudflare
183
+ // answers an `Accept: application/json` client with one) reach Relay
184
+ // as a payload — and, on the server, get serialized into a prerendered
185
+ // page that then crashed every visitor on hydration.
186
+ if (!resp.ok) {
187
+ if (attempt < 2 && isRetryableStatus(resp.status)) {
188
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
189
+ continue;
190
+ }
191
+ const detail = await errorMessageOf(resp);
192
+ throw new FontdueResponseError(`fontdue-js: ${request.name} failed with HTTP ${resp.status}` + (detail ? `: ${detail}` : ''), resp.status, request.name);
193
+ }
133
194
  const json = await resp.json();
195
+ if (!isGraphQLResponse(json)) {
196
+ throw new FontdueResponseError(`fontdue-js: ${request.name} returned HTTP ${resp.status} with a body that is not a GraphQL response`, resp.status, request.name);
197
+ }
134
198
 
135
199
  // GraphQL returns exceptions (for example, a missing required variable) in the "errors"
136
200
  // property of the response. If any exceptions occurred when processing the request,
137
201
  // throw an error to indicate to the developer what went wrong.
138
- if (Array.isArray(json.errors)) {
139
- var _json$errors, _error$extensions;
140
- const error = (_json$errors = json.errors) === null || _json$errors === void 0 ? void 0 : _json$errors[0];
202
+ const {
203
+ errors
204
+ } = json;
205
+ if (Array.isArray(errors) && errors[0]) {
206
+ var _error$extensions;
207
+ const error = errors[0];
141
208
  console.error('GraphQL Error:', {
142
209
  message: error.message,
143
210
  code: (_error$extensions = error.extensions) === null || _error$extensions === void 0 ? void 0 : _error$extensions.code,
@@ -146,9 +213,10 @@ export function createNetworkFetch(options) {
146
213
  }
147
214
  return json;
148
215
  } catch (error) {
216
+ if (error instanceof FontdueResponseError) throw error;
149
217
  // Retry on network errors (TypeError) before falling through to CORS detection
150
218
  if (attempt < 2 && error instanceof TypeError) {
151
- await new Promise(resolve => setTimeout(resolve, 1000));
219
+ await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
152
220
  continue;
153
221
  }
154
222
  if (handlePossibleCorsError(error, url)) {
@@ -46,6 +46,18 @@ export default async function loadSerializableQuery(query, variables, options) {
46
46
  if (!('params' in query)) throw new Error('Params not found in query, is it a fragment instead of a query?');
47
47
  const fetcher = createNetworkFetch(options);
48
48
  const response = await fetcher(query.params, variables);
49
+
50
+ // The result is handed to the browser and, on a prerendered page, frozen
51
+ // into the static output — so a response with no data must fail here, where
52
+ // the render fails with it (Next keeps serving the last good page; a build
53
+ // aborts), rather than be serialized and crash every visitor on hydration.
54
+ // Partial data alongside errors is still a payload Relay can render.
55
+ const singular = Array.isArray(response) ? response[0] : response;
56
+ if ((singular === null || singular === void 0 ? void 0 : singular.data) == null) {
57
+ var _singular$errors, _singular$errors$;
58
+ const detail = singular === null || singular === void 0 ? void 0 : (_singular$errors = singular.errors) === null || _singular$errors === void 0 ? void 0 : (_singular$errors$ = _singular$errors[0]) === null || _singular$errors$ === void 0 ? void 0 : _singular$errors$.message;
59
+ throw new Error(`fontdue-js: ${query.params.name} returned no data` + (detail ? `: ${detail}` : ''));
60
+ }
49
61
  return {
50
62
  params: query.params,
51
63
  variables,
@@ -18,14 +18,18 @@ export default function useSerializablePreloadedQuery(preloadQuery) {
18
18
  let query = arguments.length > 2 ? arguments[2] : undefined;
19
19
  const environment = useRelayEnvironment();
20
20
  useMemo(() => {
21
+ // A preload with no data is not a result to hand Relay: on a page that was
22
+ // prerendered while the origin was down (older fontdue-js serialized
23
+ // those) priming the cache with it would make hydration throw "No data
24
+ // returned for operation …" for every visitor. Leave the cache cold so the
25
+ // component fetches live instead.
26
+ const response = preloadQuery.response;
27
+ const singular = Array.isArray(response) ? response[0] : response;
28
+ if ((singular === null || singular === void 0 ? void 0 : singular.data) == null) return;
21
29
  writePreloadedQueryToCache(preloadQuery);
22
30
  if (query != null) {
23
- const response = preloadQuery.response;
24
- const singular = Array.isArray(response) ? response[0] : response;
25
- if ((singular === null || singular === void 0 ? void 0 : singular.data) != null) {
26
- const operation = createOperationDescriptor(getRequest(query), preloadQuery.variables);
27
- environment.commitPayload(operation, singular.data);
28
- }
31
+ const operation = createOperationDescriptor(getRequest(query), preloadQuery.variables);
32
+ environment.commitPayload(operation, singular.data);
29
33
  }
30
34
  }, [preloadQuery, environment, query]);
31
35
  return {
@@ -47,6 +47,7 @@
47
47
 
48
48
  import { resolveFontdueServerConfig, setFontdueServerConfig, resolveNodeAccessRecovery } from '../relay/serverConfig.js';
49
49
  import { PREVIEW_HEADER } from '../preview/constants.js';
50
+ import { CLIENT_VERSION_HEADER, version } from '../version.js';
50
51
  function readEnv(name) {
51
52
  if (typeof process !== 'undefined' && process.env) {
52
53
  const v = process.env[name];
@@ -146,7 +147,11 @@ export function createFontdueFetch() {
146
147
  const headers = {
147
148
  'content-type': 'application/json',
148
149
  ...(config === null || config === void 0 ? void 0 : config.headers),
149
- ...options.headers
150
+ ...options.headers,
151
+ // Which fontdue-js this site runs – the server records it per tenant so
152
+ // sites that need to upgrade for a feature can be found and warned.
153
+ // Set last so a caller's headers can't misreport it.
154
+ [CLIENT_VERSION_HEADER]: version
150
155
  };
151
156
 
152
157
  // Declare preview intent explicitly: a forwarded admin token means this is a
@@ -0,0 +1,2 @@
1
+ export declare const CLIENT_VERSION_HEADER = "fontdue-client-version";
2
+ export declare const version: string;
@@ -0,0 +1,11 @@
1
+ // `__FONTDUE_JS_VERSION__` is replaced by an inline babel plugin
2
+ // (defineVersionPlugin in .babelrc.cjs) with the literal package.json#version;
3
+ // the CDN bundle gets the same literal from vite.config.ts `define`.
4
+ //
5
+ // Every GraphQL request sends it as the `fontdue-client-version` header
6
+ // (relay/environment.ts, server/index.ts, and the script-tag bootstrap in
7
+ // assets/fontdue/index.tsx). The server records the version per tenant
8
+ // (Fontage.ClientVersions) to spot sites that need to upgrade before a
9
+ // feature that depends on a newer client can work for them.
10
+ export const CLIENT_VERSION_HEADER = 'fontdue-client-version';
11
+ export const version = "3.5.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fontdue-js",
3
- "version": "3.4.1",
3
+ "version": "3.5.1",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "build": "npm run relay && run-p build-js build-css build-ts",