fontdue-js 3.5.0 → 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 +5 -0
- package/README.md +2 -2
- package/dist/__tests__/loadSerializableQuery.test.js +73 -0
- package/dist/__tests__/networkFetch.test.js +20 -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/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/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 +14 -0
- package/dist/relay/environment.js +73 -4
- package/dist/relay/loadSerializableQuery.js +12 -0
- package/dist/relay/useSerializablePreloadedQuery.js +10 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
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
|
+
|
|
1
6
|
## 3.5.0
|
|
2
7
|
|
|
3
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.
|
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)
|
|
@@ -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
|
})
|
|
@@ -117,6 +125,8 @@ describe('createNetworkFetch (fontdue-client-version header)', () => {
|
|
|
117
125
|
it('sends the package version on every request', async () => {
|
|
118
126
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
119
127
|
const fetchMock = vi.fn(async () => ({
|
|
128
|
+
ok: true,
|
|
129
|
+
status: 200,
|
|
120
130
|
json: async () => ({
|
|
121
131
|
data: {}
|
|
122
132
|
})
|
|
@@ -133,6 +143,8 @@ describe('createNetworkFetch (fontdue-client-version header)', () => {
|
|
|
133
143
|
it('cannot be overridden by per-call headers', async () => {
|
|
134
144
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
135
145
|
const fetchMock = vi.fn(async () => ({
|
|
146
|
+
ok: true,
|
|
147
|
+
status: 200,
|
|
136
148
|
json: async () => ({
|
|
137
149
|
data: {}
|
|
138
150
|
})
|
|
@@ -153,6 +165,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
153
165
|
it('sends fontdue-preview: false on a public server fetch (no token)', async () => {
|
|
154
166
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
155
167
|
const fetchMock = vi.fn(async () => ({
|
|
168
|
+
ok: true,
|
|
169
|
+
status: 200,
|
|
156
170
|
json: async () => ({
|
|
157
171
|
data: {}
|
|
158
172
|
})
|
|
@@ -167,6 +181,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
167
181
|
it('sends fontdue-preview: true when a preview Bearer token is forwarded (server)', async () => {
|
|
168
182
|
vi.stubEnv('FONTDUE_URL', 'https://acme.fontdue.com');
|
|
169
183
|
const fetchMock = vi.fn(async () => ({
|
|
184
|
+
ok: true,
|
|
185
|
+
status: 200,
|
|
170
186
|
json: async () => ({
|
|
171
187
|
data: {}
|
|
172
188
|
})
|
|
@@ -192,6 +208,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
192
208
|
});
|
|
193
209
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
|
|
194
210
|
const fetchMock = vi.fn(async () => ({
|
|
211
|
+
ok: true,
|
|
212
|
+
status: 200,
|
|
195
213
|
json: async () => ({
|
|
196
214
|
data: {}
|
|
197
215
|
})
|
|
@@ -212,6 +230,8 @@ describe('createNetworkFetch (fontdue-preview header)', () => {
|
|
|
212
230
|
});
|
|
213
231
|
vi.stubEnv('NEXT_PUBLIC_FONTDUE_URL', 'https://acme.fontdue.com');
|
|
214
232
|
const fetchMock = vi.fn(async () => ({
|
|
233
|
+
ok: true,
|
|
234
|
+
status: 200,
|
|
215
235
|
json: async () => ({
|
|
216
236
|
data: {}
|
|
217
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
|
}
|
|
@@ -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 ?
|
|
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
|
-
|
|
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
|
-
} =
|
|
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
|
}
|
|
@@ -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,6 +1,20 @@
|
|
|
1
1
|
import { Environment, RequestParameters, QueryResponseCache, Variables, GraphQLResponse } from 'relay-runtime';
|
|
2
2
|
import { version } from '../version.js';
|
|
3
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
|
+
}
|
|
4
18
|
export declare function fontdueBaseUrl(): string | undefined;
|
|
5
19
|
export declare function createNetworkFetch(options?: CreateRelayEnvironmentOptions): (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
|
|
6
20
|
export declare const networkFetch: (request: RequestParameters, variables: Variables) => Promise<GraphQLResponse>;
|
|
@@ -47,6 +47,54 @@ const NEXT_PUBLIC_STRIPE = typeof process !== 'undefined' && process.env ? proce
|
|
|
47
47
|
const FONTDUE_URL = readEnv('FONTDUE_URL') ?? readEnv('NEXT_PUBLIC_FONTDUE_URL') ?? NEXT_PUBLIC_URL;
|
|
48
48
|
const STRIPE_INTEGRATION = readEnv('FONTDUE_STRIPE_INTEGRATION') ?? readEnv('NEXT_PUBLIC_FONTDUE_STRIPE_INTEGRATION') ?? NEXT_PUBLIC_STRIPE;
|
|
49
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
|
+
}
|
|
50
98
|
|
|
51
99
|
// The configured Fontdue base URL resolved for the current runtime, or
|
|
52
100
|
// undefined when it can't be determined (e.g. multi-tenant, where fetches go to
|
|
@@ -129,14 +177,34 @@ export function createNetworkFetch(options) {
|
|
|
129
177
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
130
178
|
try {
|
|
131
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
|
+
}
|
|
132
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
|
+
}
|
|
133
198
|
|
|
134
199
|
// GraphQL returns exceptions (for example, a missing required variable) in the "errors"
|
|
135
200
|
// property of the response. If any exceptions occurred when processing the request,
|
|
136
201
|
// throw an error to indicate to the developer what went wrong.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
202
|
+
const {
|
|
203
|
+
errors
|
|
204
|
+
} = json;
|
|
205
|
+
if (Array.isArray(errors) && errors[0]) {
|
|
206
|
+
var _error$extensions;
|
|
207
|
+
const error = errors[0];
|
|
140
208
|
console.error('GraphQL Error:', {
|
|
141
209
|
message: error.message,
|
|
142
210
|
code: (_error$extensions = error.extensions) === null || _error$extensions === void 0 ? void 0 : _error$extensions.code,
|
|
@@ -145,9 +213,10 @@ export function createNetworkFetch(options) {
|
|
|
145
213
|
}
|
|
146
214
|
return json;
|
|
147
215
|
} catch (error) {
|
|
216
|
+
if (error instanceof FontdueResponseError) throw error;
|
|
148
217
|
// Retry on network errors (TypeError) before falling through to CORS detection
|
|
149
218
|
if (attempt < 2 && error instanceof TypeError) {
|
|
150
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
|
|
151
220
|
continue;
|
|
152
221
|
}
|
|
153
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
|
|
24
|
-
|
|
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 {
|
package/dist/version.js
CHANGED