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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/dist/__generated__/PrecartAddToCartMutation.graphql.d.ts +9 -1
- package/dist/__generated__/PrecartAddToCartMutation.graphql.js +1 -1
- package/dist/__generated__/StoreModalProductSummaryAddToCartMutation.graphql.d.ts +9 -1
- package/dist/__generated__/StoreModalProductSummaryAddToCartMutation.graphql.js +1 -1
- package/dist/__tests__/createFontdueFetch.test.js +16 -0
- package/dist/__tests__/loadSerializableQuery.test.js +73 -0
- package/dist/__tests__/networkFetch.test.js +58 -0
- package/dist/__tests__/networkStatus.test.js +105 -0
- package/dist/__tests__/previewServer.test.js +1 -0
- package/dist/__tests__/providerBoundaries.test.js +56 -0
- package/dist/__tests__/useSerializablePreloadedQuery.test.js +66 -0
- package/dist/components/BuyButton/index.js +5 -5
- package/dist/components/Cart/orderTracking.d.ts +22 -5
- package/dist/components/Cart/orderTracking.js +32 -13
- package/dist/components/CharacterViewer/index.js +5 -5
- package/dist/components/CustomerLoginForm/index.js +8 -2
- package/dist/components/FeatureTester/FeatureTesters.js +4 -4
- package/dist/components/FeatureTester/index.js +2 -2
- package/dist/components/FontdueContextProvider/index.d.ts +3 -0
- package/dist/components/FontdueContextProvider/index.js +40 -5
- package/dist/components/NewsletterSignup/index.js +2 -2
- package/dist/components/Precart/index.js +3 -1
- package/dist/components/StoreModalProductSummary/index.js +3 -1
- package/dist/components/TestFontsForm/index.js +2 -2
- package/dist/components/TypeTester/TypeTesterStandalone.js +2 -2
- package/dist/components/TypeTesters/index.js +5 -5
- package/dist/relay/environment.d.ts +16 -2
- package/dist/relay/environment.js +78 -10
- package/dist/relay/loadSerializableQuery.js +12 -0
- package/dist/relay/useSerializablePreloadedQuery.js +10 -6
- package/dist/server/index.js +6 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 3.5.1
|
|
2
|
+
|
|
3
|
+
- Fixed `FontdueProvider` turning every Next.js `notFound()`, `redirect()` and thrown render error into a `200`. The provider wrapped the whole app in a Suspense boundary, so React streamed the page shell before Next could set the status – missing pages were indexed as real ones, redirects lost their `Location` header, and `error.tsx` never rendered (the error escalated to `global-error.tsx`). Each Fontdue component now carries its own boundary instead. A route’s own `loading.tsx` still streams a `200` before `notFound()` can act; that is Next.js behaviour ([vercel/next.js#82041](https://github.com/vercel/next.js/issues/82041)).
|
|
4
|
+
- A GraphQL request that fails with a non-2xx status is now an error, retried twice for 5xx and 429 responses, instead of being handed to Relay as data. A transient origin error during a prerender could be serialized into the page and crash every visitor on hydration with `No data returned for operation …` until the page was regenerated; the render now fails instead, so Next keeps serving the last good page. The browser no longer primes its cache from a preload with no data either, so pages already built that way recover by fetching live.
|
|
5
|
+
|
|
6
|
+
## 3.5.0
|
|
7
|
+
|
|
8
|
+
- Adding to the cart now sends the buyer's analytics context (cookie consent, anonymous ID, Meta's `_fbp`/`_fbc`) with the request, the same way opening the cart already did. Fontdue records a "Product Added" event for each add and a "Checkout Started" event the first time the buyer submits their contact details in checkout.
|
|
9
|
+
- `createFontdueFetch` from `fontdue-js/server` now sends the `fontdue-client-version` header, so server-rendered requests report the package version to Fontdue the way browser requests already did.
|
|
10
|
+
|
|
1
11
|
## 3.4.1
|
|
2
12
|
|
|
3
13
|
- Fixed the standalone type tester dropping an axis slider when its `variable-settings` starting value is negative. `variable-settings="slnt -11"` was rejected as unparseable, so no slant slider appeared and nothing in the markup explained why. Any axis with a negative range was affected. Extra spaces between an axis and its value are tolerated now too.
|
package/README.md
CHANGED
|
@@ -387,7 +387,7 @@ export default withFontdue({
|
|
|
387
387
|
What it installs:
|
|
388
388
|
|
|
389
389
|
- **Image settings** — `images.remotePatterns` entries for Fontdue's image hosts (plus `dangerouslyAllowSVG`, since font specimens are often SVGs), merged with your own `images` config.
|
|
390
|
-
- **
|
|
390
|
+
- **Blocking metadata** — Next's streamed metadata locks in a `200` response before a `notFound()` thrown during `generateMetadata` can take effect ([vercel/next.js#82041](https://github.com/vercel/next.js/issues/82041)). `withFontdue` sets `htmlLimitedBots` to match every user agent so metadata rendering blocks the response. This only covers `generateMetadata`: a `notFound()`, `redirect()` or error in the page itself gets its status from whether the page shell rendered, so keep the page out of any Suspense boundary you want a real status for — a route's own `loading.tsx` streams the shell as `200` before the page runs, which is the same upstream issue.
|
|
391
391
|
|
|
392
392
|
The rest of your config — `rewrites` included — passes through unchanged.
|
|
393
393
|
|
|
@@ -454,7 +454,7 @@ For a site built on the [example repo](https://github.com/fontdue/example-next)
|
|
|
454
454
|
|
|
455
455
|
v3 is ESM-only and needs `react` 18/19 and `node` >= 18. If TypeScript can't resolve the imports, set `moduleResolution` to `"bundler"` (or `node16`/`nodenext`) in `tsconfig.json` — see [Requirements](#requirements).
|
|
456
456
|
|
|
457
|
-
2. **Wrap your Next config with `withFontdue`.** It installs the Fontdue image settings (`remotePatterns`, `dangerouslyAllowSVG`) and the `htmlLimitedBots` workaround
|
|
457
|
+
2. **Wrap your Next config with `withFontdue`.** It installs the Fontdue image settings (`remotePatterns`, `dangerouslyAllowSVG`) and the `htmlLimitedBots` workaround that lets a `notFound()` in `generateMetadata` return a real 404, so you can delete those from your own config — for many sites the whole file shrinks to:
|
|
458
458
|
|
|
459
459
|
```js
|
|
460
460
|
// next.config.mjs (replaces next.config.js — the package is ESM)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @generated SignedSource<<
|
|
2
|
+
* @generated SignedSource<<74cae87a2fa5e1e7b1ee3b6482faabae>>
|
|
3
3
|
* @lightSyntaxTransform
|
|
4
4
|
* @nogrep
|
|
5
5
|
*/
|
|
@@ -10,6 +10,7 @@ export type CreateOrderItemsInput = {
|
|
|
10
10
|
licenseeIsBillingIdentity?: boolean | null;
|
|
11
11
|
orderVariableSelections?: ReadonlyArray<OrderVariableSelectionInput> | null;
|
|
12
12
|
skuIds: ReadonlyArray<string | null>;
|
|
13
|
+
tracking?: OrderTrackingInput | null;
|
|
13
14
|
};
|
|
14
15
|
export type LicenseSelectionInput = {
|
|
15
16
|
id?: string | null;
|
|
@@ -23,6 +24,13 @@ export type OrderVariableSelectionInput = {
|
|
|
23
24
|
orderVariableId: string;
|
|
24
25
|
orderVariableOptionId?: string | null;
|
|
25
26
|
};
|
|
27
|
+
export type OrderTrackingInput = {
|
|
28
|
+
analyticsConsent?: boolean | null;
|
|
29
|
+
anonymousId?: string | null;
|
|
30
|
+
fbc?: string | null;
|
|
31
|
+
fbp?: string | null;
|
|
32
|
+
url?: string | null;
|
|
33
|
+
};
|
|
26
34
|
export type PrecartAddToCartMutation$variables = {
|
|
27
35
|
input: CreateOrderItemsInput;
|
|
28
36
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @generated SignedSource<<
|
|
2
|
+
* @generated SignedSource<<fef9b63617978230edc13ef9b3412c2e>>
|
|
3
3
|
* @lightSyntaxTransform
|
|
4
4
|
* @nogrep
|
|
5
5
|
*/
|
|
@@ -10,6 +10,7 @@ export type CreateOrderItemsInput = {
|
|
|
10
10
|
licenseeIsBillingIdentity?: boolean | null;
|
|
11
11
|
orderVariableSelections?: ReadonlyArray<OrderVariableSelectionInput> | null;
|
|
12
12
|
skuIds: ReadonlyArray<string | null>;
|
|
13
|
+
tracking?: OrderTrackingInput | null;
|
|
13
14
|
};
|
|
14
15
|
export type LicenseSelectionInput = {
|
|
15
16
|
id?: string | null;
|
|
@@ -23,6 +24,13 @@ export type OrderVariableSelectionInput = {
|
|
|
23
24
|
orderVariableId: string;
|
|
24
25
|
orderVariableOptionId?: string | null;
|
|
25
26
|
};
|
|
27
|
+
export type OrderTrackingInput = {
|
|
28
|
+
analyticsConsent?: boolean | null;
|
|
29
|
+
anonymousId?: string | null;
|
|
30
|
+
fbc?: string | null;
|
|
31
|
+
fbp?: string | null;
|
|
32
|
+
url?: string | null;
|
|
33
|
+
};
|
|
26
34
|
export type StoreModalProductSummaryAddToCartMutation$variables = {
|
|
27
35
|
input: CreateOrderItemsInput;
|
|
28
36
|
};
|
|
@@ -245,6 +245,22 @@ describe('createFontdueFetch', () => {
|
|
|
245
245
|
expect(init.next).toBeUndefined();
|
|
246
246
|
});
|
|
247
247
|
});
|
|
248
|
+
describe('fontdue-client-version header', () => {
|
|
249
|
+
it('sends the package version, like the Relay layer does', async () => {
|
|
250
|
+
const fetchMock = mockFetch(() => ({
|
|
251
|
+
status: 200,
|
|
252
|
+
json: async () => ({
|
|
253
|
+
data: {}
|
|
254
|
+
})
|
|
255
|
+
}));
|
|
256
|
+
const fetchGraphql = createFontdueFetch({
|
|
257
|
+
url: 'https://acme.fontdue.com'
|
|
258
|
+
});
|
|
259
|
+
await fetchGraphql('Q', 'query Q { __typename }');
|
|
260
|
+
const init = fetchMock.mock.calls[0][1];
|
|
261
|
+
expect(init.headers['fontdue-client-version']).toBe('0.0.0-test');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
248
264
|
describe('fontdue-preview header', () => {
|
|
249
265
|
it('sends "false" by default so a public/session-only request never reveals hidden fonts', async () => {
|
|
250
266
|
const fetchMock = mockFetch(() => ({
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
// environment.ts reads env at module load, so stub env and re-import fresh.
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
vi.unstubAllEnvs();
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
});
|
|
8
|
+
const query = {
|
|
9
|
+
params: {
|
|
10
|
+
name: 'TestQuery',
|
|
11
|
+
text: 'query TestQuery { viewer { id } }',
|
|
12
|
+
operationKind: 'query',
|
|
13
|
+
metadata: {}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function respond(status, body) {
|
|
17
|
+
return {
|
|
18
|
+
ok: status >= 200 && status < 300,
|
|
19
|
+
status,
|
|
20
|
+
json: async () => body
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The serialized preload is handed to the browser and, on a prerendered page,
|
|
25
|
+
// frozen into the static output. A response with no `data` must never get
|
|
26
|
+
// that far: throwing here fails the render (Next keeps serving the last good
|
|
27
|
+
// page, a build aborts) instead of baking a payload every visitor crashes on.
|
|
28
|
+
describe('loadSerializableQuery', () => {
|
|
29
|
+
it('returns the response alongside params and variables', async () => {
|
|
30
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
31
|
+
const payload = {
|
|
32
|
+
data: {
|
|
33
|
+
viewer: {
|
|
34
|
+
id: '1'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
vi.stubGlobal('fetch', vi.fn(async () => respond(200, payload)));
|
|
39
|
+
const {
|
|
40
|
+
default: loadSerializableQuery
|
|
41
|
+
} = await import("../relay/loadSerializableQuery.js");
|
|
42
|
+
await expect(loadSerializableQuery(query, {})).resolves.toEqual({
|
|
43
|
+
params: query.params,
|
|
44
|
+
variables: {},
|
|
45
|
+
response: payload
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
it('refuses to serialize a response with no data, naming the operation and the GraphQL error', async () => {
|
|
49
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
50
|
+
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {
|
|
51
|
+
data: null,
|
|
52
|
+
errors: [{
|
|
53
|
+
message: 'Not found'
|
|
54
|
+
}]
|
|
55
|
+
})));
|
|
56
|
+
const {
|
|
57
|
+
default: loadSerializableQuery
|
|
58
|
+
} = await import("../relay/loadSerializableQuery.js");
|
|
59
|
+
await expect(loadSerializableQuery(query, {})).rejects.toThrow(/TestQuery.*Not found/);
|
|
60
|
+
});
|
|
61
|
+
it('propagates a failed request instead of serializing it', async () => {
|
|
62
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
63
|
+
vi.stubGlobal('fetch', vi.fn(async () => respond(404, {
|
|
64
|
+
errors: [{
|
|
65
|
+
message: 'Site not found'
|
|
66
|
+
}]
|
|
67
|
+
})));
|
|
68
|
+
const {
|
|
69
|
+
default: loadSerializableQuery
|
|
70
|
+
} = await import("../relay/loadSerializableQuery.js");
|
|
71
|
+
await expect(loadSerializableQuery(query, {})).rejects.toThrow(/404/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -14,6 +14,8 @@ describe('createNetworkFetch (server)', () => {
|
|
|
14
14
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
15
15
|
vi.stubEnv('NODE_ENV', 'production');
|
|
16
16
|
const fetchMock = vi.fn(async () => ({
|
|
17
|
+
ok: true,
|
|
18
|
+
status: 200,
|
|
17
19
|
json: async () => ({
|
|
18
20
|
data: {}
|
|
19
21
|
})
|
|
@@ -37,6 +39,8 @@ describe('createNetworkFetch (server)', () => {
|
|
|
37
39
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
38
40
|
vi.stubEnv('NODE_ENV', 'development');
|
|
39
41
|
const fetchMock = vi.fn(async () => ({
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
40
44
|
json: async () => ({
|
|
41
45
|
data: {}
|
|
42
46
|
})
|
|
@@ -56,6 +60,8 @@ describe('createNetworkFetch (server)', () => {
|
|
|
56
60
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
57
61
|
vi.stubEnv('NODE_ENV', 'production');
|
|
58
62
|
const fetchMock = vi.fn(async () => ({
|
|
63
|
+
ok: true,
|
|
64
|
+
status: 200,
|
|
59
65
|
json: async () => ({
|
|
60
66
|
data: {}
|
|
61
67
|
})
|
|
@@ -88,6 +94,8 @@ describe('createNetworkFetch (server)', () => {
|
|
|
88
94
|
it('forwards per-call options.headers (e.g. a preview Bearer token)', async () => {
|
|
89
95
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
90
96
|
const fetchMock = vi.fn(async () => ({
|
|
97
|
+
ok: true,
|
|
98
|
+
status: 200,
|
|
91
99
|
json: async () => ({
|
|
92
100
|
data: {}
|
|
93
101
|
})
|
|
@@ -111,10 +119,54 @@ describe('createNetworkFetch (server)', () => {
|
|
|
111
119
|
function headersOf(fetchMock) {
|
|
112
120
|
return fetchMock.mock.calls[0][1].headers;
|
|
113
121
|
}
|
|
122
|
+
describe('createNetworkFetch (fontdue-client-version header)', () => {
|
|
123
|
+
// The server records this per tenant (Fontage.ClientVersions) to find sites
|
|
124
|
+
// that must upgrade before a client-dependent feature works for them.
|
|
125
|
+
it('sends the package version on every request', async () => {
|
|
126
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
127
|
+
const fetchMock = vi.fn(async () => ({
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
130
|
+
json: async () => ({
|
|
131
|
+
data: {}
|
|
132
|
+
})
|
|
133
|
+
}));
|
|
134
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
135
|
+
const {
|
|
136
|
+
createNetworkFetch,
|
|
137
|
+
version
|
|
138
|
+
} = await import("../relay/environment.js");
|
|
139
|
+
await createNetworkFetch()(request, {});
|
|
140
|
+
expect(version).toBe('0.0.0-test');
|
|
141
|
+
expect(headersOf(fetchMock)['fontdue-client-version']).toBe('0.0.0-test');
|
|
142
|
+
});
|
|
143
|
+
it('cannot be overridden by per-call headers', async () => {
|
|
144
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
145
|
+
const fetchMock = vi.fn(async () => ({
|
|
146
|
+
ok: true,
|
|
147
|
+
status: 200,
|
|
148
|
+
json: async () => ({
|
|
149
|
+
data: {}
|
|
150
|
+
})
|
|
151
|
+
}));
|
|
152
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
153
|
+
const {
|
|
154
|
+
createNetworkFetch
|
|
155
|
+
} = await import("../relay/environment.js");
|
|
156
|
+
await createNetworkFetch({
|
|
157
|
+
headers: {
|
|
158
|
+
'fontdue-client-version': '9.9.9'
|
|
159
|
+
}
|
|
160
|
+
})(request, {});
|
|
161
|
+
expect(headersOf(fetchMock)['fontdue-client-version']).toBe('0.0.0-test');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
114
164
|
describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
115
165
|
it('sends fontdue-preview: false on a public server fetch (no token)', async () => {
|
|
116
166
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
117
167
|
const fetchMock = vi.fn(async () => ({
|
|
168
|
+
ok: true,
|
|
169
|
+
status: 200,
|
|
118
170
|
json: async () => ({
|
|
119
171
|
data: {}
|
|
120
172
|
})
|
|
@@ -129,6 +181,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
129
181
|
it('sends fontdue-preview: true when a preview Bearer token is forwarded (server)', async () => {
|
|
130
182
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
131
183
|
const fetchMock = vi.fn(async () => ({
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
132
186
|
json: async () => ({
|
|
133
187
|
data: {}
|
|
134
188
|
})
|
|
@@ -154,6 +208,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
154
208
|
});
|
|
155
209
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
|
|
156
210
|
const fetchMock = vi.fn(async () => ({
|
|
211
|
+
ok: true,
|
|
212
|
+
status: 200,
|
|
157
213
|
json: async () => ({
|
|
158
214
|
data: {}
|
|
159
215
|
})
|
|
@@ -174,6 +230,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
174
230
|
});
|
|
175
231
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
|
|
176
232
|
const fetchMock = vi.fn(async () => ({
|
|
233
|
+
ok: true,
|
|
234
|
+
status: 200,
|
|
177
235
|
json: async () => ({
|
|
178
236
|
data: {}
|
|
179
237
|
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
// environment.ts reads env at module load, so stub env and re-import fresh.
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.resetModules();
|
|
5
|
+
vi.unstubAllEnvs();
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
const request = {
|
|
10
|
+
name: 'TestQuery',
|
|
11
|
+
text: 'query TestQuery { viewer { id } }'
|
|
12
|
+
};
|
|
13
|
+
function respond(status, body) {
|
|
14
|
+
return {
|
|
15
|
+
ok: status >= 200 && status < 300,
|
|
16
|
+
status,
|
|
17
|
+
json: async () => body
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A non-2xx response is a failed request, not data. Before this, a 5xx whose
|
|
22
|
+
// body happened to parse as JSON (a Cloudflare 522 to an `Accept:
|
|
23
|
+
// application/json` client, Phoenix's own ErrorView) was returned to Relay as
|
|
24
|
+
// if it were a payload — and, on the server, serialized into the prerendered
|
|
25
|
+
// page, so every visitor hydrated into "No data returned for operation …"
|
|
26
|
+
// until the page was regenerated.
|
|
27
|
+
describe('createNetworkFetch (HTTP status handling)', () => {
|
|
28
|
+
it('retries a 5xx twice, then throws a FontdueResponseError carrying the status', async () => {
|
|
29
|
+
vi.useFakeTimers();
|
|
30
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
31
|
+
const fetchMock = vi.fn(async () => respond(522, {}));
|
|
32
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
33
|
+
const {
|
|
34
|
+
createNetworkFetch,
|
|
35
|
+
FontdueResponseError
|
|
36
|
+
} = await import("../relay/environment.js");
|
|
37
|
+
// Attach the rejection handler before advancing the clock so the retry
|
|
38
|
+
// delays don't surface as an unhandled rejection.
|
|
39
|
+
const outcome = createNetworkFetch()(request, {}).then(() => null, err => err);
|
|
40
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
41
|
+
const err = await outcome;
|
|
42
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
43
|
+
expect(err).toBeInstanceOf(FontdueResponseError);
|
|
44
|
+
expect(err.status).toBe(522);
|
|
45
|
+
expect(err.operation).toBe('TestQuery');
|
|
46
|
+
expect(err.message).toContain('522');
|
|
47
|
+
expect(err.message).toContain('TestQuery');
|
|
48
|
+
});
|
|
49
|
+
it('recovers when a retry succeeds', async () => {
|
|
50
|
+
vi.useFakeTimers();
|
|
51
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
52
|
+
const payload = {
|
|
53
|
+
data: {
|
|
54
|
+
viewer: {
|
|
55
|
+
id: '1'
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const fetchMock = vi.fn().mockResolvedValueOnce(respond(503, {})).mockResolvedValueOnce(respond(200, payload));
|
|
60
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
61
|
+
const {
|
|
62
|
+
createNetworkFetch
|
|
63
|
+
} = await import("../relay/environment.js");
|
|
64
|
+
const pending = createNetworkFetch()(request, {});
|
|
65
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
66
|
+
await expect(pending).resolves.toEqual(payload);
|
|
67
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
68
|
+
});
|
|
69
|
+
it('throws on a 4xx without retrying, surfacing the GraphQL message when the body has one', async () => {
|
|
70
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
71
|
+
const fetchMock = vi.fn(async () => respond(404, {
|
|
72
|
+
errors: [{
|
|
73
|
+
message: 'Site not found'
|
|
74
|
+
}]
|
|
75
|
+
}));
|
|
76
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
77
|
+
const {
|
|
78
|
+
createNetworkFetch
|
|
79
|
+
} = await import("../relay/environment.js");
|
|
80
|
+
await expect(createNetworkFetch()(request, {})).rejects.toThrow(/404.*Site not found/);
|
|
81
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
it('throws when a 2xx body is not a GraphQL response', async () => {
|
|
84
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
85
|
+
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {})));
|
|
86
|
+
const {
|
|
87
|
+
createNetworkFetch
|
|
88
|
+
} = await import("../relay/environment.js");
|
|
89
|
+
await expect(createNetworkFetch()(request, {})).rejects.toThrow(/TestQuery/);
|
|
90
|
+
});
|
|
91
|
+
it('returns a 2xx GraphQL error payload unchanged so Relay reports the message', async () => {
|
|
92
|
+
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
93
|
+
const payload = {
|
|
94
|
+
data: null,
|
|
95
|
+
errors: [{
|
|
96
|
+
message: 'Not found'
|
|
97
|
+
}]
|
|
98
|
+
};
|
|
99
|
+
vi.stubGlobal('fetch', vi.fn(async () => respond(200, payload)));
|
|
100
|
+
const {
|
|
101
|
+
createNetworkFetch
|
|
102
|
+
} = await import("../relay/environment.js");
|
|
103
|
+
await expect(createNetworkFetch()(request, {})).resolves.toEqual(payload);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToString } from 'react-dom/server';
|
|
3
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
// The provider pulls in modules that declare `graphql` tagged queries at load
|
|
6
|
+
// time; without the Relay Babel transform the tag throws, so stub it — nothing
|
|
7
|
+
// rendered here runs a query.
|
|
8
|
+
vi.mock('react-relay', async importActual => {
|
|
9
|
+
// react-relay is CommonJS: its named exports sit under `default` here.
|
|
10
|
+
const actual = await importActual();
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
...actual.default,
|
|
14
|
+
graphql: () => ({})
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
import FontdueContextProvider, { EnsureFontdueContext, LazyQueryBoundary } from '../components/FontdueContextProvider/index.js';
|
|
18
|
+
function Boom() {
|
|
19
|
+
throw new Error('page render error');
|
|
20
|
+
}
|
|
21
|
+
function Suspender() {
|
|
22
|
+
throw new Promise(() => {});
|
|
23
|
+
}
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
vi.restoreAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// A library provider must not wrap the host app in a Suspense boundary. Next
|
|
29
|
+
// decides a response's status from whether the shell renders: with a boundary
|
|
30
|
+
// above every page, `notFound()`, `redirect()` and thrown errors were caught
|
|
31
|
+
// inside it, the shell streamed as 200, and error.tsx never got a chance
|
|
32
|
+
// (vercel/next.js#82041 is the same mechanism for a route's own loading.tsx).
|
|
33
|
+
describe('FontdueContextProvider', () => {
|
|
34
|
+
it('lets an error thrown by the host tree propagate during SSR', () => {
|
|
35
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
36
|
+
expect(() => renderToString( /*#__PURE__*/React.createElement(FontdueContextProvider, null, /*#__PURE__*/React.createElement(Boom, null)))).toThrow('page render error');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The boundary the provider used to supply now lives with each lazy renderer,
|
|
41
|
+
// so a component that lazy-fetches blanks only itself while it loads, not the
|
|
42
|
+
// page. EnsureFontdueContext itself adds none: a boundary around a preloaded
|
|
43
|
+
// component's server HTML is hydrated lazily by React, and on a Next 16 page
|
|
44
|
+
// that left every Fontdue component unhydrated until clicked.
|
|
45
|
+
describe('LazyQueryBoundary', () => {
|
|
46
|
+
it('gives a lazy renderer its own Suspense boundary', () => {
|
|
47
|
+
const html = renderToString( /*#__PURE__*/React.createElement(FontdueContextProvider, null, /*#__PURE__*/React.createElement("p", null, "host content"), /*#__PURE__*/React.createElement(EnsureFontdueContext, null, /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(Suspender, null)))));
|
|
48
|
+
expect(html).toContain('host content');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('EnsureFontdueContext', () => {
|
|
52
|
+
it('adds no boundary of its own, so a preloaded component hydrates with the page', () => {
|
|
53
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
54
|
+
expect(() => renderToString( /*#__PURE__*/React.createElement(FontdueContextProvider, null, /*#__PURE__*/React.createElement(EnsureFontdueContext, null, /*#__PURE__*/React.createElement(Boom, null))))).toThrow('page render error');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { renderToString } from 'react-dom/server';
|
|
4
|
+
import { RelayEnvironmentProvider } from 'react-relay';
|
|
5
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.resetModules();
|
|
8
|
+
vi.unstubAllEnvs();
|
|
9
|
+
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
|
|
10
|
+
});
|
|
11
|
+
const params = {
|
|
12
|
+
name: 'TestQuery',
|
|
13
|
+
cacheID: 'test-query-cache-id',
|
|
14
|
+
text: 'query TestQuery { viewer { id } }',
|
|
15
|
+
operationKind: 'query'
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// A page that was prerendered while the origin was down carries a preload
|
|
19
|
+
// with no `data` (older fontdue-js serialized those). Priming the browser's
|
|
20
|
+
// response cache with it hands Relay the empty payload on hydration and every
|
|
21
|
+
// visitor gets "No data returned for operation …" — the fetch has to go to
|
|
22
|
+
// the network instead.
|
|
23
|
+
describe('useSerializablePreloadedQuery (browser cache priming)', () => {
|
|
24
|
+
async function prime(response) {
|
|
25
|
+
const {
|
|
26
|
+
createEnvironment,
|
|
27
|
+
responseCache
|
|
28
|
+
} = await import("../relay/environment.js");
|
|
29
|
+
const {
|
|
30
|
+
default: useSerializablePreloadedQuery
|
|
31
|
+
} = await import("../relay/useSerializablePreloadedQuery.js");
|
|
32
|
+
function Probe() {
|
|
33
|
+
useSerializablePreloadedQuery({
|
|
34
|
+
params,
|
|
35
|
+
variables: {},
|
|
36
|
+
response
|
|
37
|
+
});
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
renderToString( /*#__PURE__*/React.createElement(RelayEnvironmentProvider, {
|
|
41
|
+
environment: createEnvironment({})
|
|
42
|
+
}, /*#__PURE__*/React.createElement(Probe, null)));
|
|
43
|
+
return responseCache.get(params.cacheID, {});
|
|
44
|
+
}
|
|
45
|
+
it('warms the response cache with a preload that has data', async () => {
|
|
46
|
+
var _await$prime;
|
|
47
|
+
const payload = {
|
|
48
|
+
data: {
|
|
49
|
+
viewer: {
|
|
50
|
+
id: '1'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
// Relay stamps the cached copy with `extensions.cacheTimestamp`.
|
|
55
|
+
expect((_await$prime = await prime(payload)) === null || _await$prime === void 0 ? void 0 : _await$prime.data).toEqual(payload.data);
|
|
56
|
+
});
|
|
57
|
+
it('leaves the cache cold for a preload with no data', async () => {
|
|
58
|
+
expect(await prime({})).toBeNull();
|
|
59
|
+
expect(await prime({
|
|
60
|
+
data: null,
|
|
61
|
+
errors: [{
|
|
62
|
+
message: 'Not found'
|
|
63
|
+
}]
|
|
64
|
+
})).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -13,7 +13,7 @@ import BuyButtonIDQueryNode from '../../__generated__/BuyButtonIDQuery.graphql.j
|
|
|
13
13
|
import BuyButtonSlugQueryNode from '../../__generated__/BuyButtonSlugQuery.graphql.js';
|
|
14
14
|
import loadSerializableQuery from '../../relay/loadSerializableQuery.js';
|
|
15
15
|
import useSerializablePreloadedQuery from '../../relay/useSerializablePreloadedQuery.js';
|
|
16
|
-
import { EnsureFontdueContext } from '../FontdueContextProvider/index.js';
|
|
16
|
+
import { EnsureFontdueContext, LazyQueryBoundary } from '../FontdueContextProvider/index.js';
|
|
17
17
|
function BuyButtonComponent(_ref) {
|
|
18
18
|
let {
|
|
19
19
|
collection: collectionKey,
|
|
@@ -153,17 +153,17 @@ export default function BuyButton(props) {
|
|
|
153
153
|
collectionId,
|
|
154
154
|
...rest
|
|
155
155
|
} = props;
|
|
156
|
-
inner = /*#__PURE__*/React.createElement(BuyButtonIDQueryRenderer, _extends({
|
|
156
|
+
inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(BuyButtonIDQueryRenderer, _extends({
|
|
157
157
|
collectionId: collectionId
|
|
158
|
-
}, rest));
|
|
158
|
+
}, rest)));
|
|
159
159
|
} else if ('collectionSlug' in props && props.collectionSlug) {
|
|
160
160
|
const {
|
|
161
161
|
collectionSlug,
|
|
162
162
|
...rest
|
|
163
163
|
} = props;
|
|
164
|
-
inner = /*#__PURE__*/React.createElement(BuyButtonSlugQueryRenderer, _extends({
|
|
164
|
+
inner = /*#__PURE__*/React.createElement(LazyQueryBoundary, null, /*#__PURE__*/React.createElement(BuyButtonSlugQueryRenderer, _extends({
|
|
165
165
|
collectionSlug: collectionSlug
|
|
166
|
-
}, rest));
|
|
166
|
+
}, rest)));
|
|
167
167
|
} else {
|
|
168
168
|
throw new Error('BuyButton expected one of preloadedQuery, collectionId, or collectionSlug');
|
|
169
169
|
}
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
import { Environment } from 'relay-runtime';
|
|
2
|
+
export type OrderTrackingInput = {
|
|
3
|
+
analyticsConsent: boolean;
|
|
4
|
+
anonymousId?: string;
|
|
5
|
+
fbp?: string;
|
|
6
|
+
fbc?: string;
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
2
9
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
10
|
+
* The buyer's analytics context as the order mutations take it: cookie
|
|
11
|
+
* consent, the anonymous ID, Meta's browser IDs (`_fbp`/`_fbc`) and the page.
|
|
12
|
+
* Sent with add-to-cart (`createOrderItems`) and cart/checkout open
|
|
13
|
+
* (`updateOrderTracking`) alike, so the server captures the same identifiers
|
|
14
|
+
* on every event it emits for the order — "Product Added", "Checkout Started"
|
|
15
|
+
* (which the server emits when the buyer submits their contact details, not
|
|
16
|
+
* from these calls) and the purchase, which fires from a Stripe webhook with
|
|
17
|
+
* no browser at all.
|
|
7
18
|
*
|
|
8
|
-
*
|
|
19
|
+
* Returns undefined outside a browser or if anything throws: tracking must
|
|
20
|
+
* never break the cart.
|
|
21
|
+
*/
|
|
22
|
+
export declare function orderTrackingInput(): OrderTrackingInput | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Stores the buyer's analytics context on the current order as the cart or
|
|
25
|
+
* checkout opens. Fire-and-forget: tracking must never break checkout.
|
|
9
26
|
*/
|
|
10
27
|
export declare function sendOrderTracking(environment: Environment): void;
|