@akinon/pz-theme 2.0.98-rc.0 → 2.0.98
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 +7 -33
- package/jest/setup.ts +18 -7
- package/package.json +4 -4
- package/src/__tests__/history-restore-recovery.test.tsx +452 -0
- package/src/chrome/__tests__/theme-fonts-css.test.ts +109 -0
- package/src/chrome/theme-fonts-css.ts +52 -0
- package/src/chrome/theme-fonts-link.tsx +44 -0
- package/src/chrome/theme-restore-guard.tsx +148 -0
- package/src/index.ts +18 -2
- package/src/theme-placeholder-wrapper.tsx +171 -79
- package/src/theme-placeholder.tsx +2 -0
- package/src/utils/custom-fonts.ts +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,38 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.98
|
|
4
|
-
|
|
5
|
-
### Patch Changes
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
- Updated dependencies [51ea06888]
|
|
11
|
-
- Updated dependencies
|
|
12
|
-
- Updated dependencies [2fea41430]
|
|
13
|
-
- Updated dependencies [b55acb768]
|
|
14
|
-
- Updated dependencies [760258c1c]
|
|
15
|
-
- Updated dependencies [143be2b9d]
|
|
16
|
-
- Updated dependencies [7889b08fe]
|
|
17
|
-
- Updated dependencies [9f8cd3bc5]
|
|
18
|
-
- Updated dependencies [bfafa3f49]
|
|
19
|
-
- Updated dependencies [57d7eb305]
|
|
20
|
-
- Updated dependencies [d99a6a7d5]
|
|
21
|
-
- Updated dependencies [9db81a714]
|
|
22
|
-
- Updated dependencies [591e345e1]
|
|
23
|
-
- Updated dependencies [4de5303c5]
|
|
24
|
-
- Updated dependencies [95b139dc1]
|
|
25
|
-
- Updated dependencies [1d00f2d06]
|
|
26
|
-
- Updated dependencies [4ac7b2a1e]
|
|
27
|
-
- Updated dependencies [e9598c71d]
|
|
28
|
-
- Updated dependencies [4998a9631]
|
|
29
|
-
- Updated dependencies [804d2bd6c]
|
|
30
|
-
- Updated dependencies [3909d3224]
|
|
31
|
-
- Updated dependencies [6a3d8a631]
|
|
32
|
-
- Updated dependencies [e18836b20]
|
|
33
|
-
- Updated dependencies [b1111d7b]
|
|
34
|
-
- @akinon/next@2.0.98-rc.0
|
|
35
|
-
- @akinon/theme-contract@2.0.98-rc.0
|
|
3
|
+
## 2.0.98
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 23fde979: ZERO-5004: implement theme font management and restore guard
|
|
8
|
+
- @akinon/next@2.0.98
|
|
9
|
+
- @akinon/theme-contract@2.0.98
|
|
36
10
|
|
|
37
11
|
## 2.0.97
|
|
38
12
|
|
package/jest/setup.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import '@testing-library/jest-dom';
|
|
2
2
|
|
|
3
|
+
// A suite can opt into the node environment (`@jest-environment node`) when it
|
|
4
|
+
// tests server-only code — a route handler needs the real `Request`/`Response`,
|
|
5
|
+
// which jsdom does not have. There is no `window` there, and everything below
|
|
6
|
+
// is a jsdom shim, so it stands down instead of throwing before the first test.
|
|
7
|
+
const hasWindow = typeof window !== 'undefined';
|
|
8
|
+
|
|
3
9
|
// jsdom does not implement these; provide no-op shims so components that touch
|
|
4
10
|
// them on mount don't crash under test.
|
|
5
|
-
if (!window.matchMedia) {
|
|
11
|
+
if (hasWindow && !window.matchMedia) {
|
|
6
12
|
window.matchMedia = ((query: string) => ({
|
|
7
13
|
matches: false,
|
|
8
14
|
media: query,
|
|
@@ -15,7 +21,9 @@ if (!window.matchMedia) {
|
|
|
15
21
|
})) as unknown as typeof window.matchMedia;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
if (hasWindow) {
|
|
25
|
+
window.scrollTo = (() => {}) as typeof window.scrollTo;
|
|
26
|
+
}
|
|
19
27
|
|
|
20
28
|
// `react-dom/server` builds a MessageChannel at module scope, and jsdom has
|
|
21
29
|
// none — importing it in a jsdom suite throws before a single test runs. Node's
|
|
@@ -76,8 +84,11 @@ class NoopObserver {
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
(
|
|
80
|
-
(window as unknown as Record<string, unknown>).IntersectionObserver
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
(window as unknown as Record<string, unknown>).ResizeObserver
|
|
87
|
+
if (hasWindow) {
|
|
88
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
89
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver ||
|
|
90
|
+
NoopObserver;
|
|
91
|
+
(window as unknown as Record<string, unknown>).ResizeObserver =
|
|
92
|
+
(window as unknown as Record<string, unknown>).ResizeObserver ||
|
|
93
|
+
NoopObserver;
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akinon/pz-theme",
|
|
3
3
|
"description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
|
|
4
|
-
"version": "2.0.98
|
|
4
|
+
"version": "2.0.98",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"test": "jest"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@akinon/next": "2.0.98
|
|
11
|
+
"@akinon/next": "2.0.98",
|
|
12
12
|
"react": "^18.0.0 || ^19.0.0",
|
|
13
13
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@akinon/theme-contract": "2.0.98
|
|
16
|
+
"@akinon/theme-contract": "2.0.98",
|
|
17
17
|
"clsx": "2.1.1",
|
|
18
18
|
"lottie-web": "^5.13.0",
|
|
19
19
|
"react-multi-carousel": "2.8.4",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"zod": "4.4.1"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@akinon/next": "2.0.98
|
|
24
|
+
"@akinon/next": "2.0.98",
|
|
25
25
|
"@testing-library/jest-dom": "5.16.4",
|
|
26
26
|
"@testing-library/react": "16.3.2",
|
|
27
27
|
"@testing-library/user-event": "14.1.1",
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, act } from '@testing-library/react';
|
|
3
|
+
// The Node build, not the browser one: it is the path a storefront actually
|
|
4
|
+
// server-renders through, and the browser build wants a MessageChannel jsdom
|
|
5
|
+
// does not have.
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server.node';
|
|
7
|
+
|
|
8
|
+
import ThemePlaceholderWrapper, {
|
|
9
|
+
armHistoryRestoreGuard,
|
|
10
|
+
buildCustomFontFaceCss,
|
|
11
|
+
buildRestoreGuardScript
|
|
12
|
+
} from '../theme-placeholder-wrapper';
|
|
13
|
+
import {
|
|
14
|
+
ThemeFontsLink,
|
|
15
|
+
THEME_FONTS_CSS_PATH
|
|
16
|
+
} from '../chrome/theme-fonts-link';
|
|
17
|
+
import { isEmptyRestoredDocument } from '../chrome/theme-restore-guard';
|
|
18
|
+
import type { Section } from '../theme-section';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ZERO-5004 — a page came back from a back/forward navigation with its brand
|
|
22
|
+
* font replaced by the fallback stack, and pages whose body is client-rendered
|
|
23
|
+
* came back blank.
|
|
24
|
+
*
|
|
25
|
+
* The cause is not in any one page: a history navigation can be served the
|
|
26
|
+
* document out of the HTTP cache, and that copy does not always finish
|
|
27
|
+
* hydrating. Everything the client owned was then missing — the uploaded
|
|
28
|
+
* fonts, the theme colours, every effect — while the server markup still
|
|
29
|
+
* painted, so the page looked "loaded" but wrong.
|
|
30
|
+
*
|
|
31
|
+
* Three things have to hold for that to be recoverable:
|
|
32
|
+
* 1. the faces ship with the markup, so typography never depends on effects;
|
|
33
|
+
* 2. a document that did not hydrate reloads itself once, from plain markup;
|
|
34
|
+
* 3. a frozen (bfcache) restore reloads in the editor too, where the canvas
|
|
35
|
+
* used to be left blank on purpose.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// This jsdom has no performance entry API at all, so the navigation type the
|
|
39
|
+
// guard reads is provided rather than spied on.
|
|
40
|
+
const originalPerformance = window.performance;
|
|
41
|
+
|
|
42
|
+
// The whole object, not a property on it: reading `window.performance` in this
|
|
43
|
+
// environment does not hand back something you can define a property on.
|
|
44
|
+
const setNavigationType = (type: string) => {
|
|
45
|
+
Object.defineProperty(window, 'performance', {
|
|
46
|
+
configurable: true,
|
|
47
|
+
writable: true,
|
|
48
|
+
value: {
|
|
49
|
+
getEntriesByType: (kind: string) =>
|
|
50
|
+
kind === 'navigation' ? [{ type }] : []
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const restorePerformance = () => {
|
|
56
|
+
Object.defineProperty(window, 'performance', {
|
|
57
|
+
configurable: true,
|
|
58
|
+
writable: true,
|
|
59
|
+
value: originalPerformance
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const stubReload = () => {
|
|
64
|
+
// jsdom refuses to redefine `location.reload`, so the whole location object
|
|
65
|
+
// is swapped for one that carries a spy and answers `href` the same way.
|
|
66
|
+
const reload = jest.fn();
|
|
67
|
+
const original = window.location;
|
|
68
|
+
// A plain object, not one created from the real Location: its `href` getter
|
|
69
|
+
// throws when it is read off a derived object, and the guard reads `href`.
|
|
70
|
+
const stub = {
|
|
71
|
+
href: original.href,
|
|
72
|
+
origin: original.origin,
|
|
73
|
+
pathname: original.pathname,
|
|
74
|
+
reload
|
|
75
|
+
} as unknown as Location;
|
|
76
|
+
|
|
77
|
+
beforeAll(() => {
|
|
78
|
+
Object.defineProperty(window, 'location', {
|
|
79
|
+
configurable: true,
|
|
80
|
+
writable: true,
|
|
81
|
+
value: stub
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(() => {
|
|
86
|
+
Object.defineProperty(window, 'location', {
|
|
87
|
+
configurable: true,
|
|
88
|
+
writable: true,
|
|
89
|
+
value: original
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return reload;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const CUSTOM_FONTS = [
|
|
97
|
+
{
|
|
98
|
+
name: 'GantModernV2-Regular',
|
|
99
|
+
sources: [{ dataUrl: 'data:font/woff2;base64,AAAA', format: 'woff2' }]
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'GantSerif-MediumCondensed',
|
|
103
|
+
sources: [
|
|
104
|
+
{ dataUrl: 'data:font/woff2;base64,BBBB', format: 'woff2' },
|
|
105
|
+
{ dataUrl: 'data:font/woff;base64,CCCC', format: 'woff' }
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const THEME_SETTINGS = {
|
|
111
|
+
backgroundColor: '#ffffff',
|
|
112
|
+
customFonts: CUSTOM_FONTS
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const section = {
|
|
116
|
+
id: 'body-section',
|
|
117
|
+
type: 'generic-section',
|
|
118
|
+
name: 'body',
|
|
119
|
+
label: 'Body',
|
|
120
|
+
order: 0,
|
|
121
|
+
hidden: false,
|
|
122
|
+
properties: {},
|
|
123
|
+
styles: {},
|
|
124
|
+
blocks: []
|
|
125
|
+
} as unknown as Section;
|
|
126
|
+
|
|
127
|
+
const slot = (
|
|
128
|
+
settings: Record<string, unknown> | null = THEME_SETTINGS,
|
|
129
|
+
isDesignMode = false
|
|
130
|
+
) => (
|
|
131
|
+
<ThemePlaceholderWrapper
|
|
132
|
+
slug="page-iletisim"
|
|
133
|
+
initialSections={[section]}
|
|
134
|
+
initialPlaceholderId="page-iletisim"
|
|
135
|
+
isDesignMode={isDesignMode}
|
|
136
|
+
initialThemeSettings={settings}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// What a page actually carries: the link the placeholder renders, then the
|
|
141
|
+
// placeholder itself.
|
|
142
|
+
const composedPage = (
|
|
143
|
+
settings: Record<string, unknown> | null = THEME_SETTINGS
|
|
144
|
+
) => (
|
|
145
|
+
<>
|
|
146
|
+
<ThemeFontsLink settings={settings} />
|
|
147
|
+
{slot(settings)}
|
|
148
|
+
</>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
describe('uploaded faces reach the browser without hydration', () => {
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
document
|
|
154
|
+
.querySelectorAll('link[data-pz-theme-fonts], style[id^="custom-font-"]')
|
|
155
|
+
.forEach((node) => node.remove());
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('builds one @font-face per uploaded face', () => {
|
|
159
|
+
const css = buildCustomFontFaceCss(THEME_SETTINGS);
|
|
160
|
+
|
|
161
|
+
expect(css.match(/@font-face/g)).toHaveLength(2);
|
|
162
|
+
expect(css).toContain("font-family:'GantModernV2-Regular'");
|
|
163
|
+
expect(css).toContain("font-family:'GantSerif-MediumCondensed'");
|
|
164
|
+
expect(css).toContain("url('data:font/woff2;base64,AAAA') format('woff2')");
|
|
165
|
+
expect(css).toContain('font-display:swap');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('keeps both sources of a face, in order', () => {
|
|
169
|
+
const css = buildCustomFontFaceCss(THEME_SETTINGS);
|
|
170
|
+
const serif = css.split('@font-face').find((r) => r.includes('GantSerif'));
|
|
171
|
+
|
|
172
|
+
expect(serif).toContain(
|
|
173
|
+
"url('data:font/woff2;base64,BBBB') format('woff2'), url('data:font/woff;base64,CCCC') format('woff')"
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('skips entries with no name or no usable source', () => {
|
|
178
|
+
expect(
|
|
179
|
+
buildCustomFontFaceCss({
|
|
180
|
+
customFonts: [
|
|
181
|
+
{ name: '', sources: [{ dataUrl: 'data:x', format: 'woff2' }] },
|
|
182
|
+
{ name: 'NoSources', sources: [] },
|
|
183
|
+
{ name: 'Broken', sources: [{ format: 'woff2' }] }
|
|
184
|
+
]
|
|
185
|
+
})
|
|
186
|
+
).toBe('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('cannot break out of the style tag', () => {
|
|
190
|
+
const css = buildCustomFontFaceCss({
|
|
191
|
+
customFonts: [
|
|
192
|
+
{
|
|
193
|
+
name: 'Evil</style><script>alert(1)</script>',
|
|
194
|
+
sources: [{ dataUrl: 'data:font/woff2;base64,AAAA', format: 'woff2' }]
|
|
195
|
+
}
|
|
196
|
+
]
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Only `</style` can end the element, and it comes out escaped; the rest
|
|
200
|
+
// of the crafted name stays inert text inside the stylesheet.
|
|
201
|
+
expect(css).not.toMatch(/<\/style/i);
|
|
202
|
+
expect(css).toContain('<\\/style>');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('has nothing to build for a theme with no uploads', () => {
|
|
206
|
+
expect(buildCustomFontFaceCss(null)).toBe('');
|
|
207
|
+
expect(buildCustomFontFaceCss({ backgroundColor: '#fff' })).toBe('');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('links the stylesheet from the server render, so a page that never hydrates still has the faces', () => {
|
|
211
|
+
const html = renderToStaticMarkup(composedPage());
|
|
212
|
+
|
|
213
|
+
expect(html).toContain(`href="${THEME_FONTS_CSS_PATH}"`);
|
|
214
|
+
expect(html).toContain('data-pz-theme-fonts="true"');
|
|
215
|
+
// The blobs stay out of the document: they are the stylesheet's payload,
|
|
216
|
+
// cached across pages instead of serialized into every response.
|
|
217
|
+
expect(html).not.toContain('base64,AAAA');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('links nothing for a theme with no uploads', () => {
|
|
221
|
+
expect(
|
|
222
|
+
renderToStaticMarkup(composedPage({ backgroundColor: '#fff' }))
|
|
223
|
+
).not.toContain('data-pz-theme-fonts');
|
|
224
|
+
expect(renderToStaticMarkup(<ThemeFontsLink settings={null} />)).toBe('');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const linkTheStylesheet = () => {
|
|
228
|
+
const link = document.createElement('link');
|
|
229
|
+
link.setAttribute('data-pz-theme-fonts', 'true');
|
|
230
|
+
link.setAttribute('href', THEME_FONTS_CSS_PATH);
|
|
231
|
+
document.head.appendChild(link);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// The real FontFaceSet is iterable and lists every declared face, loaded or
|
|
235
|
+
// not; `check()` is deliberately not part of the stub, because asking it was
|
|
236
|
+
// the bug (it answers false for a declared-but-unloaded face).
|
|
237
|
+
const stubFontApi = (declared: string[]) => {
|
|
238
|
+
Object.defineProperty(document, 'fonts', {
|
|
239
|
+
configurable: true,
|
|
240
|
+
writable: true,
|
|
241
|
+
value: {
|
|
242
|
+
ready: Promise.resolve(),
|
|
243
|
+
[Symbol.iterator]: () =>
|
|
244
|
+
declared
|
|
245
|
+
.map((family) => ({ family, status: 'unloaded' }))
|
|
246
|
+
[Symbol.iterator]()
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
it('does not inline the faces from the client when the stylesheet delivered them', async () => {
|
|
252
|
+
linkTheStylesheet();
|
|
253
|
+
stubFontApi(['GantModernV2-Regular', '"GantSerif-MediumCondensed"']);
|
|
254
|
+
|
|
255
|
+
const { unmount } = render(slot());
|
|
256
|
+
// The check waits for `document.fonts.ready`.
|
|
257
|
+
await act(async () => {});
|
|
258
|
+
|
|
259
|
+
expect(document.querySelectorAll('style[id^="custom-font-"]').length).toBe(
|
|
260
|
+
0
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
unmount();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('inlines them when the stylesheet is linked but delivered nothing', async () => {
|
|
267
|
+
// A brand-side route that 404s, or a theme-config read that failed while
|
|
268
|
+
// answering it: the family ends up declared nowhere, and the page would
|
|
269
|
+
// otherwise render in the fallback face.
|
|
270
|
+
linkTheStylesheet();
|
|
271
|
+
stubFontApi([]);
|
|
272
|
+
|
|
273
|
+
const { unmount } = render(slot());
|
|
274
|
+
await act(async () => {});
|
|
275
|
+
|
|
276
|
+
expect(document.querySelectorAll('style[id^="custom-font-"]').length).toBe(
|
|
277
|
+
2
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
unmount();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('still inlines them when the brand has not mounted the route', () => {
|
|
284
|
+
// The fallback path: without the stylesheet the faces would otherwise be
|
|
285
|
+
// missing entirely, so the effect keeps its old job.
|
|
286
|
+
const { unmount } = render(slot());
|
|
287
|
+
|
|
288
|
+
expect(document.querySelectorAll('style[id^="custom-font-"]').length).toBe(
|
|
289
|
+
2
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
unmount();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('a document that did not hydrate reloads itself once', () => {
|
|
297
|
+
const reload = stubReload();
|
|
298
|
+
|
|
299
|
+
const runGuard = (navType: string) => {
|
|
300
|
+
// A restored page that still shows its server markup: the guard waits for
|
|
301
|
+
// hydration rather than reloading on the spot.
|
|
302
|
+
document.body.innerHTML = '<main>Restored server markup</main>';
|
|
303
|
+
Object.defineProperty(document.body, 'innerText', {
|
|
304
|
+
configurable: true,
|
|
305
|
+
value: 'Restored server markup'
|
|
306
|
+
});
|
|
307
|
+
jest.useFakeTimers();
|
|
308
|
+
reload.mockClear();
|
|
309
|
+
window.sessionStorage.clear();
|
|
310
|
+
delete (window as Record<string, unknown>).__pzRestoreGuardArmed;
|
|
311
|
+
setNavigationType(navType);
|
|
312
|
+
|
|
313
|
+
armHistoryRestoreGuard(1500);
|
|
314
|
+
jest.advanceTimersByTime(2000);
|
|
315
|
+
jest.useRealTimers();
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
beforeEach(() => {
|
|
319
|
+
// A render earlier in this file set the flag; the guard must be judged on
|
|
320
|
+
// what THIS document announced.
|
|
321
|
+
delete (window as Record<string, unknown>).__pzThemeHydrated;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
afterEach(() => {
|
|
325
|
+
jest.restoreAllMocks();
|
|
326
|
+
delete (performance as unknown as Record<string, unknown>).getEntriesByType;
|
|
327
|
+
delete (window as Record<string, unknown>).__pzThemeHydrated;
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('reloads a back/forward entry that never announced a mount', () => {
|
|
331
|
+
runGuard('back_forward');
|
|
332
|
+
|
|
333
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('leaves a hydrated restore alone', () => {
|
|
337
|
+
(window as Record<string, unknown>).__pzThemeHydrated = true;
|
|
338
|
+
runGuard('back_forward');
|
|
339
|
+
|
|
340
|
+
expect(reload).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('leaves a normal navigation alone', () => {
|
|
344
|
+
runGuard('navigate');
|
|
345
|
+
|
|
346
|
+
expect(reload).not.toHaveBeenCalled();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('treats a shell with no markup as nothing to wait for', () => {
|
|
350
|
+
// The 404 and error documents have no server markup — their body only
|
|
351
|
+
// exists once React runs — so the guard reloads them on the spot instead
|
|
352
|
+
// of showing a white screen for the length of the grace period.
|
|
353
|
+
const empty = new DOMParser().parseFromString(
|
|
354
|
+
'<html id="__next_error__"><body></body></html>',
|
|
355
|
+
'text/html'
|
|
356
|
+
);
|
|
357
|
+
const painted = new DOMParser().parseFromString(
|
|
358
|
+
'<html><body><main>Restored server markup</main></body></html>',
|
|
359
|
+
'text/html'
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
expect(isEmptyRestoredDocument(empty)).toBe(true);
|
|
363
|
+
expect(isEmptyRestoredDocument(painted)).toBe(false);
|
|
364
|
+
expect(
|
|
365
|
+
isEmptyRestoredDocument(
|
|
366
|
+
new DOMParser().parseFromString(
|
|
367
|
+
'<html><body> </body></html>',
|
|
368
|
+
'text/html'
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('does not reload the same entry twice', () => {
|
|
375
|
+
runGuard('back_forward');
|
|
376
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
377
|
+
|
|
378
|
+
// Second visit to the same history entry: the mark from the first reload
|
|
379
|
+
// is still in sessionStorage, so the guard stands down instead of looping.
|
|
380
|
+
reload.mockClear();
|
|
381
|
+
delete (window as Record<string, unknown>).__pzRestoreGuardArmed;
|
|
382
|
+
jest.useFakeTimers();
|
|
383
|
+
setNavigationType('back_forward');
|
|
384
|
+
armHistoryRestoreGuard(1500);
|
|
385
|
+
jest.advanceTimersByTime(2000);
|
|
386
|
+
jest.useRealTimers();
|
|
387
|
+
|
|
388
|
+
expect(reload).not.toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('ships with the framework bundles, not inside our tree', () => {
|
|
392
|
+
// Deliberately NOT rendered by a component of ours: on an error or 404
|
|
393
|
+
// document React streams the body — head scripts included — through the
|
|
394
|
+
// RSC payload, where a script tag is text and never runs. See
|
|
395
|
+
// theme-restore-guard.tsx; the brand arms it from instrumentation-client.
|
|
396
|
+
expect(renderToStaticMarkup(composedPage())).not.toContain(
|
|
397
|
+
'pz-restore-guard'
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('can also be shipped as an inline script, same logic', () => {
|
|
402
|
+
const script = buildRestoreGuardScript(1200);
|
|
403
|
+
|
|
404
|
+
expect(script).toContain('__pzThemeHydrated');
|
|
405
|
+
expect(script).toContain('back_forward');
|
|
406
|
+
expect(script).toContain('.reload()');
|
|
407
|
+
expect(script.trimEnd().endsWith('(1200);')).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe('a mounted placeholder announces itself', () => {
|
|
412
|
+
it('sets the flag the guard waits for', () => {
|
|
413
|
+
delete (window as Record<string, unknown>).__pzThemeHydrated;
|
|
414
|
+
const { unmount } = render(slot());
|
|
415
|
+
|
|
416
|
+
expect((window as Record<string, unknown>).__pzThemeHydrated).toBe(true);
|
|
417
|
+
unmount();
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('a frozen restore recovers in the editor too', () => {
|
|
422
|
+
const reload = stubReload();
|
|
423
|
+
|
|
424
|
+
beforeEach(() => reload.mockClear());
|
|
425
|
+
|
|
426
|
+
const firePageShow = (persisted: boolean) => {
|
|
427
|
+
act(() => {
|
|
428
|
+
const event = new Event('pageshow') as Event & { persisted?: boolean };
|
|
429
|
+
Object.defineProperty(event, 'persisted', { value: persisted });
|
|
430
|
+
window.dispatchEvent(event);
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
it('reloads the canvas the editor is attached to', () => {
|
|
435
|
+
// The designer used to be exempt and got a blank canvas instead.
|
|
436
|
+
const { unmount } = render(slot(THEME_SETTINGS, true));
|
|
437
|
+
|
|
438
|
+
firePageShow(true);
|
|
439
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
440
|
+
|
|
441
|
+
unmount();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('still ignores a normal pageshow', () => {
|
|
445
|
+
const { unmount } = render(slot(THEME_SETTINGS, true));
|
|
446
|
+
|
|
447
|
+
firePageShow(false);
|
|
448
|
+
expect(reload).not.toHaveBeenCalled();
|
|
449
|
+
|
|
450
|
+
unmount();
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
// The route runs on the server, where `Request`/`Response` are globals; the
|
|
5
|
+
// default jsdom environment has neither.
|
|
6
|
+
jest.mock('server-only', () => ({}));
|
|
7
|
+
|
|
8
|
+
const getThemeConfig = jest.fn();
|
|
9
|
+
jest.mock('../theme-config-source', () => ({
|
|
10
|
+
getThemeConfig: () => getThemeConfig()
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { themeFontsCssResponse } from '../theme-fonts-css';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The route behind the font stylesheet. It exists so the faces stop depending
|
|
17
|
+
* on hydration having run (ZERO-5004) and stop being serialized into every
|
|
18
|
+
* page: a stylesheet is fetched once and revalidated, not shipped per response.
|
|
19
|
+
*/
|
|
20
|
+
const SETTINGS = {
|
|
21
|
+
customFonts: [
|
|
22
|
+
{
|
|
23
|
+
name: 'GantModernV2-Regular',
|
|
24
|
+
sources: [{ dataUrl: 'data:font/woff2;base64,AAAA', format: 'woff2' }]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('the theme font stylesheet route', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
getThemeConfig.mockReset();
|
|
32
|
+
getThemeConfig.mockResolvedValue({
|
|
33
|
+
settings: SETTINGS,
|
|
34
|
+
dataSources: [],
|
|
35
|
+
modals: []
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('answers with the faces as CSS', async () => {
|
|
40
|
+
const response = await themeFontsCssResponse();
|
|
41
|
+
|
|
42
|
+
expect(response.status).toBe(200);
|
|
43
|
+
expect(response.headers.get('content-type')).toBe(
|
|
44
|
+
'text/css; charset=utf-8'
|
|
45
|
+
);
|
|
46
|
+
await expect(response.text()).resolves.toContain(
|
|
47
|
+
"@font-face{font-family:'GantModernV2-Regular'"
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is cacheable but revalidated, so an upload is never stale', async () => {
|
|
52
|
+
const response = await themeFontsCssResponse();
|
|
53
|
+
|
|
54
|
+
expect(response.headers.get('cache-control')).toBe(
|
|
55
|
+
'public, max-age=0, must-revalidate'
|
|
56
|
+
);
|
|
57
|
+
expect(response.headers.get('etag')).toMatch(/^W\/"pz-fonts-/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('answers 304 for a browser that already has this version', async () => {
|
|
61
|
+
const first = await themeFontsCssResponse();
|
|
62
|
+
const etag = first.headers.get('etag') as string;
|
|
63
|
+
|
|
64
|
+
const second = await themeFontsCssResponse(
|
|
65
|
+
new Request('http://localhost/api/theme-fonts', {
|
|
66
|
+
headers: { 'if-none-match': etag }
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(second.status).toBe(304);
|
|
71
|
+
await expect(second.text()).resolves.toBe('');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('changes its tag when the designer uploads a different face', async () => {
|
|
75
|
+
const before = (await themeFontsCssResponse()).headers.get('etag');
|
|
76
|
+
|
|
77
|
+
getThemeConfig.mockResolvedValue({
|
|
78
|
+
settings: {
|
|
79
|
+
customFonts: [
|
|
80
|
+
{
|
|
81
|
+
name: 'GantModernV2-Regular',
|
|
82
|
+
sources: [
|
|
83
|
+
{ dataUrl: 'data:font/woff2;base64,ZZZZ', format: 'woff2' }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
dataSources: [],
|
|
89
|
+
modals: []
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect((await themeFontsCssResponse()).headers.get('etag')).not.toBe(
|
|
93
|
+
before
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('answers empty CSS for a theme with no uploads', async () => {
|
|
98
|
+
getThemeConfig.mockResolvedValue({
|
|
99
|
+
settings: { backgroundColor: '#fff' },
|
|
100
|
+
dataSources: [],
|
|
101
|
+
modals: []
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const response = await themeFontsCssResponse();
|
|
105
|
+
|
|
106
|
+
expect(response.status).toBe(200);
|
|
107
|
+
await expect(response.text()).resolves.toBe('');
|
|
108
|
+
});
|
|
109
|
+
});
|