@akinon/pz-theme 2.0.98-beta.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 +4 -4
- 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,12 +1,12 @@
|
|
|
1
1
|
# @akinon/pz-theme
|
|
2
2
|
|
|
3
|
-
## 2.0.98
|
|
3
|
+
## 2.0.98
|
|
4
4
|
|
|
5
5
|
### Patch Changes
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
- @akinon/next@2.0.98
|
|
9
|
-
- @akinon/theme-contract@2.0.98
|
|
7
|
+
- 23fde979: ZERO-5004: implement theme font management and restore guard
|
|
8
|
+
- @akinon/next@2.0.98
|
|
9
|
+
- @akinon/theme-contract@2.0.98
|
|
10
10
|
|
|
11
11
|
## 2.0.97
|
|
12
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
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { getThemeConfig } from './theme-config-source';
|
|
4
|
+
import { buildCustomFontFaceCss } from '../utils/custom-fonts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The route body behind THEME_FONTS_CSS_PATH: the theme's uploaded faces as a
|
|
8
|
+
* real stylesheet.
|
|
9
|
+
*
|
|
10
|
+
* Mount it in the brand app as `app/api/theme-fonts/route.ts`:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { themeFontsCssResponse } from '@akinon/pz-theme/src/chrome/theme-fonts-css';
|
|
14
|
+
*
|
|
15
|
+
* export const GET = () => themeFontsCssResponse();
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Answered with a weak ETag over the CSS so a designer's upload invalidates it
|
|
19
|
+
* immediately while a shopper browsing the store revalidates in one round trip
|
|
20
|
+
* instead of re-downloading the faces per page.
|
|
21
|
+
*/
|
|
22
|
+
export const themeFontsCssResponse = async (
|
|
23
|
+
request?: Request
|
|
24
|
+
): Promise<Response> => {
|
|
25
|
+
const { settings } = await getThemeConfig();
|
|
26
|
+
const css = buildCustomFontFaceCss(settings);
|
|
27
|
+
|
|
28
|
+
// A cheap, order-stable digest — enough to tell one upload from the next.
|
|
29
|
+
let hash = 0;
|
|
30
|
+
for (let index = 0; index < css.length; index += 1) {
|
|
31
|
+
hash = (hash * 31 + css.charCodeAt(index)) | 0;
|
|
32
|
+
}
|
|
33
|
+
const etag = `W/"pz-fonts-${css.length.toString(36)}-${(hash >>> 0).toString(
|
|
34
|
+
36
|
|
35
|
+
)}"`;
|
|
36
|
+
|
|
37
|
+
const headers = {
|
|
38
|
+
'Content-Type': 'text/css; charset=utf-8',
|
|
39
|
+
// Revalidated on every navigation, served from cache when unchanged: the
|
|
40
|
+
// faces are large and change only when the designer uploads. An EMPTY
|
|
41
|
+
// answer is never cached — it means the theme has no uploads yet, or that
|
|
42
|
+
// the widget read failed, and neither should stick to the browser.
|
|
43
|
+
'Cache-Control': css ? 'public, max-age=0, must-revalidate' : 'no-store',
|
|
44
|
+
ETag: etag
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (request?.headers.get('if-none-match') === etag) {
|
|
48
|
+
return new Response(null, { status: 304, headers });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Response(css, { status: 200, headers });
|
|
52
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where a brand mounts the theme's font stylesheet.
|
|
5
|
+
*
|
|
6
|
+
* The faces a designer uploads are base64 blobs in the theme settings, and they
|
|
7
|
+
* used to reach the browser only through an effect that appended `<style>` tags
|
|
8
|
+
* on mount. That made the brand's typography depend on hydration having run,
|
|
9
|
+
* and hydration does not always run: a history navigation can be served a
|
|
10
|
+
* cached copy of the document whose React tree never comes up, and the page
|
|
11
|
+
* then paints every word in the fallback face (ZERO-5004).
|
|
12
|
+
*
|
|
13
|
+
* A stylesheet has neither problem. It is part of the document, the browser
|
|
14
|
+
* fetches it before first paint, and it is cached across pages instead of being
|
|
15
|
+
* serialized into every response.
|
|
16
|
+
*/
|
|
17
|
+
export const THEME_FONTS_CSS_PATH = '/api/theme-fonts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rendered by every placeholder; React keeps ONE of them, matched by `href`,
|
|
21
|
+
* and hoists it into the head.
|
|
22
|
+
*/
|
|
23
|
+
export const ThemeFontsLink = ({
|
|
24
|
+
settings
|
|
25
|
+
}: {
|
|
26
|
+
settings: Record<string, unknown> | null;
|
|
27
|
+
}) => {
|
|
28
|
+
const fonts = settings?.customFonts;
|
|
29
|
+
|
|
30
|
+
// Nothing uploaded: no request to make. A theme that adds its first face
|
|
31
|
+
// later gets the link on that page's next render.
|
|
32
|
+
if (!Array.isArray(fonts) || fonts.length === 0) return null;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<link
|
|
36
|
+
rel="stylesheet"
|
|
37
|
+
href={THEME_FONTS_CSS_PATH}
|
|
38
|
+
// Read by the client font effect, which stands down when the document
|
|
39
|
+
// already links the faces.
|
|
40
|
+
data-pz-theme-fonts="true"
|
|
41
|
+
precedence="pz-theme-fonts"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A back/forward navigation can be served the document out of the HTTP cache
|
|
3
|
+
* (Chrome does this even for `Cache-Control: no-cache`), and the copy it
|
|
4
|
+
* replays does not always finish hydrating: the markup paints, no effect ever
|
|
5
|
+
* runs, and everything the client owns is missing — the uploaded fonts, the
|
|
6
|
+
* theme colours, every interaction. A page whose body is client-rendered (the
|
|
7
|
+
* 404 shell, a data-driven composition) comes back blank (ZERO-5004).
|
|
8
|
+
*
|
|
9
|
+
* Nothing inside React can notice that, because React is exactly what did not
|
|
10
|
+
* start. So the check ships as plain markup: on a `back_forward` entry it waits
|
|
11
|
+
* for the mount below to announce itself and reloads once if that never
|
|
12
|
+
* happens. `sessionStorage` keeps it to one reload per history entry, and a
|
|
13
|
+
* normal navigation clears the mark so a later restore can still recover.
|
|
14
|
+
*/
|
|
15
|
+
export const RESTORE_GUARD_TIMEOUT_MS = 1500;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Arms the check. Self-contained on purpose: it is also shipped as a string
|
|
19
|
+
* (see `buildRestoreGuardScript`), so it may not reference anything in this
|
|
20
|
+
* module's scope.
|
|
21
|
+
*
|
|
22
|
+
* The natural home is the brand's `instrumentation-client.ts`, which Next
|
|
23
|
+
* bundles into the framework scripts:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { armHistoryRestoreGuard } from '@akinon/pz-theme/src/chrome/theme-restore-guard';
|
|
27
|
+
*
|
|
28
|
+
* armHistoryRestoreGuard();
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* A `<script>` rendered by a component of ours would not do. On an error or 404
|
|
32
|
+
* document React streams the whole body — head scripts included — through the
|
|
33
|
+
* RSC payload, so the tag exists only as payload text that the restored copy
|
|
34
|
+
* never executes. That is the document that came back blank, and the framework
|
|
35
|
+
* bundles are the only code the browser still runs there.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* Whether the restored document has anything on screen to lose.
|
|
39
|
+
*
|
|
40
|
+
* Two very different restores, and waiting the same grace period for both is
|
|
41
|
+
* what made the fix visible: a page that came back with its server markup looks
|
|
42
|
+
* right while it waits, but the 404 and error shells have no markup at all —
|
|
43
|
+
* their body only exists once React runs — so the visitor watched a white
|
|
44
|
+
* screen for as long as the timer.
|
|
45
|
+
*/
|
|
46
|
+
export const isEmptyRestoredDocument = (doc: Document = document): boolean => {
|
|
47
|
+
if (doc.getElementById('__next_error__')) return true;
|
|
48
|
+
|
|
49
|
+
const body = doc.body;
|
|
50
|
+
if (!body) return true;
|
|
51
|
+
|
|
52
|
+
// `innerText` is layout-aware in a browser and undefined in some test
|
|
53
|
+
// realms; the text content is the honest fallback.
|
|
54
|
+
const text = body.innerText ?? body.textContent ?? '';
|
|
55
|
+
|
|
56
|
+
return text.trim().length === 0;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const armHistoryRestoreGuard = (timeoutMs: number = 1500) => {
|
|
60
|
+
try {
|
|
61
|
+
const w = window as unknown as Record<string, unknown>;
|
|
62
|
+
if (w.__pzRestoreGuardArmed) return;
|
|
63
|
+
w.__pzRestoreGuardArmed = 1;
|
|
64
|
+
|
|
65
|
+
// Read defensively: not every environment exposes the performance entry
|
|
66
|
+
// API on `window` (a jsdom test realm is one), and without the navigation
|
|
67
|
+
// type there is nothing to decide.
|
|
68
|
+
const perf =
|
|
69
|
+
(window as unknown as { performance?: Performance }).performance ??
|
|
70
|
+
(globalThis as unknown as { performance?: Performance }).performance;
|
|
71
|
+
const entry =
|
|
72
|
+
perf && typeof perf.getEntriesByType === 'function'
|
|
73
|
+
? (perf.getEntriesByType('navigation')[0] as
|
|
74
|
+
| { type?: string }
|
|
75
|
+
| undefined)
|
|
76
|
+
: undefined;
|
|
77
|
+
const key = 'pz-restore-reload:' + window.location.href;
|
|
78
|
+
|
|
79
|
+
// Not a restore: clear the mark so a LATER restore of this entry can still
|
|
80
|
+
// recover.
|
|
81
|
+
if (!entry || entry.type !== 'back_forward') {
|
|
82
|
+
try {
|
|
83
|
+
window.sessionStorage.removeItem(key);
|
|
84
|
+
} catch {
|
|
85
|
+
/* private mode */
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Already reloaded once for this entry — never loop.
|
|
92
|
+
if (window.sessionStorage.getItem(key)) return;
|
|
93
|
+
} catch {
|
|
94
|
+
/* private mode */
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const reload = () => {
|
|
98
|
+
if ((window as unknown as Record<string, unknown>).__pzThemeHydrated) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
window.sessionStorage.setItem(key, '1');
|
|
104
|
+
} catch {
|
|
105
|
+
/* private mode */
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
window.location.reload();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Two very different restores, and waiting the same grace period for both
|
|
112
|
+
// is what made the fix visible: a page that came back with its server
|
|
113
|
+
// markup looks right while it waits, but the 404 and error shells have no
|
|
114
|
+
// markup at all — their body only exists once React runs — so the visitor
|
|
115
|
+
// watched a white screen for as long as the timer. When there is nothing on
|
|
116
|
+
// screen to lose, reload at once.
|
|
117
|
+
const armGrace = () => {
|
|
118
|
+
if (isEmptyRestoredDocument()) {
|
|
119
|
+
reload();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
window.setTimeout(reload, timeoutMs);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (document.readyState === 'loading') {
|
|
127
|
+
document.addEventListener('DOMContentLoaded', armGrace, { once: true });
|
|
128
|
+
} else {
|
|
129
|
+
armGrace();
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// A browser without the performance entry API loses the recovery, not the
|
|
133
|
+
// page.
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The same check as an inline script, for a brand that would rather put it in
|
|
139
|
+
* its layout than in `instrumentation-client.ts`. Prefer the latter: a script
|
|
140
|
+
* a component renders is payload text on the very documents that need this.
|
|
141
|
+
*/
|
|
142
|
+
export const buildRestoreGuardScript = (
|
|
143
|
+
timeoutMs: number = RESTORE_GUARD_TIMEOUT_MS
|
|
144
|
+
): string =>
|
|
145
|
+
`(${armHistoryRestoreGuard.toString()})(${Math.max(
|
|
146
|
+
0,
|
|
147
|
+
Math.trunc(timeoutMs)
|
|
148
|
+
)});`;
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
export { default as ThemePlaceholder } from './theme-placeholder';
|
|
5
5
|
export { default as ThemeSection } from './theme-section';
|
|
6
6
|
export { default as ThemeBlock } from './theme-block';
|
|
7
|
-
export {
|
|
8
|
-
|
|
7
|
+
export {
|
|
8
|
+
ThemeSettingsProvider,
|
|
9
|
+
useThemeSettingsContext
|
|
10
|
+
} from './theme-settings-context';
|
|
11
|
+
export {
|
|
12
|
+
ThemePageContextProvider,
|
|
13
|
+
useThemePageContext
|
|
14
|
+
} from './theme-page-context';
|
|
9
15
|
export {
|
|
10
16
|
registerPlaceholder,
|
|
11
17
|
unregisterPlaceholder
|
|
@@ -25,6 +31,16 @@ export type { Section } from './theme-section';
|
|
|
25
31
|
export type { Block } from './theme-block';
|
|
26
32
|
|
|
27
33
|
export { default as ChromeSlot } from './chrome/chrome-slot';
|
|
34
|
+
export {
|
|
35
|
+
ThemeFontsLink,
|
|
36
|
+
THEME_FONTS_CSS_PATH
|
|
37
|
+
} from './chrome/theme-fonts-link';
|
|
38
|
+
export { buildCustomFontFaceCss } from './utils/custom-fonts';
|
|
39
|
+
export {
|
|
40
|
+
armHistoryRestoreGuard,
|
|
41
|
+
buildRestoreGuardScript,
|
|
42
|
+
RESTORE_GUARD_TIMEOUT_MS
|
|
43
|
+
} from './chrome/theme-restore-guard';
|
|
28
44
|
export { default as ComposableRegion } from './chrome/composable-region';
|
|
29
45
|
export { ChromeFallback } from './chrome/chrome-fallback';
|
|
30
46
|
export { CodeOwnedRegion } from './chrome/code-owned-region';
|
|
@@ -28,6 +28,26 @@ import { PreviewToEditorMessageType } from '@akinon/theme-contract';
|
|
|
28
28
|
import { parseEditorMessage, postToEditor } from './editor-bridge';
|
|
29
29
|
import { useBrowseMode } from './browse-mode';
|
|
30
30
|
import { neutralizeStyleTagBreakout } from './utils/css-safety';
|
|
31
|
+
import { buildCustomFontFaceCss } from './utils/custom-fonts';
|
|
32
|
+
|
|
33
|
+
// Re-exported for the tests and for a brand that builds the faces itself; it
|
|
34
|
+
// lives in ./utils/custom-fonts because a server route has to call it too and
|
|
35
|
+
// this module is client-only.
|
|
36
|
+
export { buildCustomFontFaceCss };
|
|
37
|
+
export {
|
|
38
|
+
armHistoryRestoreGuard,
|
|
39
|
+
buildRestoreGuardScript,
|
|
40
|
+
RESTORE_GUARD_TIMEOUT_MS
|
|
41
|
+
} from './chrome/theme-restore-guard';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether this document already links the theme's font stylesheet. When it
|
|
45
|
+
* does, the faces are the browser's business and the client must not inline
|
|
46
|
+
* them a second time.
|
|
47
|
+
*/
|
|
48
|
+
const themeFontsLinked = () =>
|
|
49
|
+
typeof document !== 'undefined' &&
|
|
50
|
+
Boolean(document.querySelector('link[data-pz-theme-fonts]'));
|
|
31
51
|
|
|
32
52
|
// One pageshow listener per page, not per placeholder — see the effect below.
|
|
33
53
|
let bfcacheReloadListenerArmed = false;
|
|
@@ -353,10 +373,17 @@ export default function ThemePlaceholderWrapper({
|
|
|
353
373
|
// does not reliably survive the freeze, and the page comes back with its
|
|
354
374
|
// data-driven sections blank until a manual refresh. Reloading once on a
|
|
355
375
|
// persisted pageshow trades the bfcache speedup for a page that is correct.
|
|
376
|
+
// The editor used to be exempt, on the assumption that a designer would
|
|
377
|
+
// rather keep the frozen canvas than lose it to a reload. What they actually
|
|
378
|
+
// got was a blank canvas after every back/forward (ZERO-5004): the same
|
|
379
|
+
// client-fetched data goes missing, and the editor cannot repaint what the
|
|
380
|
+
// page never re-fetched. A reload recovers by itself — the storefront
|
|
381
|
+
// announces its placeholders on the new document and the editor answers with
|
|
382
|
+
// the theme it holds, so nothing unsaved is lost.
|
|
383
|
+
//
|
|
356
384
|
// Guarded at module level — a page hosts several placeholders and one
|
|
357
385
|
// listener is enough.
|
|
358
386
|
useEffect(() => {
|
|
359
|
-
if (isDesigner) return;
|
|
360
387
|
if (bfcacheReloadListenerArmed) return;
|
|
361
388
|
bfcacheReloadListenerArmed = true;
|
|
362
389
|
|
|
@@ -369,7 +396,14 @@ export default function ThemePlaceholderWrapper({
|
|
|
369
396
|
window.removeEventListener('pageshow', onPageShow);
|
|
370
397
|
bfcacheReloadListenerArmed = false;
|
|
371
398
|
};
|
|
372
|
-
}, [
|
|
399
|
+
}, []);
|
|
400
|
+
|
|
401
|
+
// What the restore guard above waits for: proof that this document's React
|
|
402
|
+
// tree actually came up.
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
(window as unknown as { __pzThemeHydrated?: boolean }).__pzThemeHydrated =
|
|
405
|
+
true;
|
|
406
|
+
}, []);
|
|
373
407
|
const [dataSources, setDataSources] = useState<any[]>(initialDataSources);
|
|
374
408
|
const [themeSettings, setThemeSettings] = useState<Record<
|
|
375
409
|
string,
|
|
@@ -486,7 +520,9 @@ export default function ThemePlaceholderWrapper({
|
|
|
486
520
|
context.order.items.length > 0
|
|
487
521
|
) {
|
|
488
522
|
const order = context.order;
|
|
489
|
-
const totalAmount = Number(
|
|
523
|
+
const totalAmount = Number(
|
|
524
|
+
String(order.totalAmount ?? '').replace(',', '.')
|
|
525
|
+
);
|
|
490
526
|
emitOnce(String(order.number ?? order.id ?? ''), 'purchase', {
|
|
491
527
|
currency: readItemsCurrency(order),
|
|
492
528
|
...(Number.isFinite(totalAmount) ? { value: totalAmount } : null),
|
|
@@ -507,7 +543,14 @@ export default function ThemePlaceholderWrapper({
|
|
|
507
543
|
}
|
|
508
544
|
}, [themeSettings]);
|
|
509
545
|
|
|
510
|
-
// Inject custom fonts from theme settings
|
|
546
|
+
// Inject custom fonts from theme settings.
|
|
547
|
+
//
|
|
548
|
+
// The faces normally arrive as the stylesheet the server linked
|
|
549
|
+
// (chrome/theme-fonts-css.ts), which is what makes them survive a document
|
|
550
|
+
// that never hydrates (ZERO-5004). This is the safety net for the two cases
|
|
551
|
+
// the stylesheet cannot cover: a brand that has not mounted the route, and a
|
|
552
|
+
// theme-config read that failed while answering it. Either way the family is
|
|
553
|
+
// then not declared anywhere, and the check below is what notices.
|
|
511
554
|
useEffect(() => {
|
|
512
555
|
if (!themeSettings?.customFonts) return;
|
|
513
556
|
const customFonts = themeSettings.customFonts as {
|
|
@@ -515,9 +558,9 @@ export default function ThemePlaceholderWrapper({
|
|
|
515
558
|
sources: { dataUrl: string; format: string }[];
|
|
516
559
|
}[];
|
|
517
560
|
|
|
518
|
-
|
|
561
|
+
const inject = (font: typeof customFonts[number]) => {
|
|
519
562
|
if (
|
|
520
|
-
!font
|
|
563
|
+
!font?.name ||
|
|
521
564
|
!Array.isArray(font.sources) ||
|
|
522
565
|
font.sources.length === 0
|
|
523
566
|
)
|
|
@@ -526,18 +569,63 @@ export default function ThemePlaceholderWrapper({
|
|
|
526
569
|
const styleId = `custom-font-${font.name
|
|
527
570
|
.replace(/\s+/g, '-')
|
|
528
571
|
.toLowerCase()}`;
|
|
529
|
-
const existing = document.getElementById(styleId);
|
|
530
|
-
if (existing) existing.remove(); // Re-inject in case sources changed
|
|
531
572
|
|
|
532
573
|
const src = font.sources
|
|
533
574
|
.map((s) => `url('${s.dataUrl}') format('${s.format}')`)
|
|
534
575
|
.join(', ');
|
|
535
576
|
|
|
577
|
+
const existing = document.getElementById(styleId);
|
|
578
|
+
if (existing) existing.remove(); // Re-inject in case sources changed
|
|
579
|
+
|
|
536
580
|
const style = document.createElement('style');
|
|
537
581
|
style.id = styleId;
|
|
538
582
|
style.textContent = `@font-face { font-family: '${font.name}'; src: ${src}; font-display: swap; }`;
|
|
539
583
|
document.head.appendChild(style);
|
|
540
|
-
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
if (!themeFontsLinked()) {
|
|
587
|
+
customFonts.forEach(inject);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let cancelled = false;
|
|
592
|
+
const injectWhateverIsMissing = () => {
|
|
593
|
+
if (cancelled) return;
|
|
594
|
+
|
|
595
|
+
customFonts.forEach((font) => {
|
|
596
|
+
if (!font?.name) return;
|
|
597
|
+
|
|
598
|
+
// Declared, not loaded: a face the stylesheet carries stays
|
|
599
|
+
// `unloaded` until some text asks for it, and `document.fonts.check`
|
|
600
|
+
// answers false for exactly that state — asking it here re-inlined
|
|
601
|
+
// every face on every page.
|
|
602
|
+
try {
|
|
603
|
+
const declared = Array.from(
|
|
604
|
+
document.fonts as unknown as Iterable<FontFace>
|
|
605
|
+
).some(
|
|
606
|
+
(face) => face.family?.replace(/^['"]|['"]$/g, '') === font.name
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
if (declared) return;
|
|
610
|
+
} catch {
|
|
611
|
+
// No font API to ask: fall through and inline, which is what this
|
|
612
|
+
// effect did before the stylesheet existed.
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
inject(font);
|
|
616
|
+
});
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const ready = document.fonts?.ready;
|
|
620
|
+
if (ready && typeof ready.then === 'function') {
|
|
621
|
+
ready.then(injectWhateverIsMissing, injectWhateverIsMissing);
|
|
622
|
+
} else {
|
|
623
|
+
injectWhateverIsMissing();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return () => {
|
|
627
|
+
cancelled = true;
|
|
628
|
+
};
|
|
541
629
|
}, [themeSettings]);
|
|
542
630
|
|
|
543
631
|
useEffect(() => {
|
|
@@ -912,18 +1000,20 @@ export default function ThemePlaceholderWrapper({
|
|
|
912
1000
|
// has to sit above the rendered sections or every data-driven block
|
|
913
1001
|
// falls back to its empty state.
|
|
914
1002
|
<ThemePageContextProvider value={resolvedPageContext}>
|
|
915
|
-
|
|
916
|
-
|
|
1003
|
+
<ThemeSettingsProvider value={themeSettings}>
|
|
1004
|
+
{/*
|
|
917
1005
|
Pins are released while browsing — a button frozen in its hover state
|
|
918
1006
|
is not what the live site looks like. The editor re-sends the pinned
|
|
919
1007
|
state when the designer comes back, so the panel and the canvas agree.
|
|
920
1008
|
*/}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1009
|
+
<PartStatePreview isDesigner={isEditing} />
|
|
1010
|
+
{themeBackgroundCSS && (
|
|
1011
|
+
<style dangerouslySetInnerHTML={{ __html: themeBackgroundCSS }} />
|
|
1012
|
+
)}
|
|
1013
|
+
{dynamicCSS && (
|
|
1014
|
+
<style dangerouslySetInnerHTML={{ __html: dynamicCSS }} />
|
|
1015
|
+
)}
|
|
1016
|
+
{/*
|
|
927
1017
|
Deliberately unpainted. This wrapper sits behind every section of the
|
|
928
1018
|
slot, so any colour on it showed through a section that asked for
|
|
929
1019
|
`background-color: transparent` — the shipped default. That is why a
|
|
@@ -932,68 +1022,70 @@ export default function ThemePlaceholderWrapper({
|
|
|
932
1022
|
(see buildThemeBackgroundCss), which paints the same pixels everywhere
|
|
933
1023
|
the slot does not overlap something else.
|
|
934
1024
|
*/}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1025
|
+
<div data-placeholder={slug} className="theme-placeholder">
|
|
1026
|
+
{renderedSections
|
|
1027
|
+
.sort((a, b) => a.order - b.order)
|
|
1028
|
+
.filter(
|
|
1029
|
+
(section) =>
|
|
1030
|
+
!section.hidden &&
|
|
1031
|
+
(isEditing ||
|
|
1032
|
+
isPublishWindowVisible(
|
|
1033
|
+
section.properties?.['publish-window']
|
|
1034
|
+
))
|
|
1035
|
+
)
|
|
1036
|
+
.map((section) => (
|
|
1037
|
+
<SectionErrorBoundary key={section.id} sectionId={section.id}>
|
|
1038
|
+
<ThemeSection
|
|
1039
|
+
section={section}
|
|
1040
|
+
placeholderId={placeholderSlug}
|
|
1041
|
+
pageContext={resolvedPageContext}
|
|
1042
|
+
isDesigner={isEditing}
|
|
1043
|
+
isSelected={selectedSectionId === section.id}
|
|
1044
|
+
selectedBlockId={selectedBlockId}
|
|
1045
|
+
currentBreakpoint={currentBreakpoint}
|
|
1046
|
+
onSelect={setSelectedSectionId}
|
|
1047
|
+
onMoveUp={() =>
|
|
1048
|
+
sendAction('MOVE_SECTION_UP', {
|
|
1049
|
+
placeholderId: placeholderSlug,
|
|
1050
|
+
sectionId: section.id
|
|
1051
|
+
})
|
|
1052
|
+
}
|
|
1053
|
+
onMoveDown={() =>
|
|
1054
|
+
sendAction('MOVE_SECTION_DOWN', {
|
|
1055
|
+
placeholderId: placeholderSlug,
|
|
1056
|
+
sectionId: section.id
|
|
1057
|
+
})
|
|
1058
|
+
}
|
|
1059
|
+
onDuplicate={() =>
|
|
1060
|
+
sendAction('DUPLICATE_SECTION', {
|
|
1061
|
+
placeholderId: placeholderSlug,
|
|
1062
|
+
sectionId: section.id
|
|
1063
|
+
})
|
|
1064
|
+
}
|
|
1065
|
+
onToggleVisibility={() =>
|
|
1066
|
+
sendAction('TOGGLE_SECTION_VISIBILITY', {
|
|
1067
|
+
placeholderId: placeholderSlug,
|
|
1068
|
+
sectionId: section.id
|
|
1069
|
+
})
|
|
1070
|
+
}
|
|
1071
|
+
onDelete={() =>
|
|
1072
|
+
sendAction('DELETE_SECTION', {
|
|
1073
|
+
placeholderId: placeholderSlug,
|
|
1074
|
+
sectionId: section.id
|
|
1075
|
+
})
|
|
1076
|
+
}
|
|
1077
|
+
onRename={(newLabel) =>
|
|
1078
|
+
sendAction('RENAME_SECTION', {
|
|
1079
|
+
placeholderId: placeholderSlug,
|
|
1080
|
+
sectionId: section.id,
|
|
1081
|
+
label: newLabel
|
|
1082
|
+
})
|
|
1083
|
+
}
|
|
1084
|
+
/>
|
|
1085
|
+
</SectionErrorBoundary>
|
|
1086
|
+
))}
|
|
1087
|
+
</div>
|
|
1088
|
+
</ThemeSettingsProvider>
|
|
997
1089
|
</ThemePageContextProvider>
|
|
998
1090
|
);
|
|
999
1091
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
|
|
3
3
|
import ThemePlaceholderWrapper from './theme-placeholder-wrapper';
|
|
4
|
+
import { ThemeFontsLink } from './chrome/theme-fonts-link';
|
|
4
5
|
import { loadPlaceholderSections } from './load-placeholder-sections';
|
|
5
6
|
import { generateThemeCSS } from './utils';
|
|
6
7
|
|
|
@@ -24,6 +25,7 @@ export default async function ThemePlaceholder({
|
|
|
24
25
|
|
|
25
26
|
return (
|
|
26
27
|
<>
|
|
28
|
+
<ThemeFontsLink settings={themeSettings} />
|
|
27
29
|
{themeCSS && <style dangerouslySetInnerHTML={{ __html: themeCSS }} />}
|
|
28
30
|
<ThemePlaceholderWrapper
|
|
29
31
|
slug={slug}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { neutralizeStyleTagBreakout } from './css-safety';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The designer's uploaded faces as real CSS, emitted from the same render that
|
|
5
|
+
* produced the markup.
|
|
6
|
+
*
|
|
7
|
+
* They used to exist only as `<style>` tags an effect appended on mount, which
|
|
8
|
+
* made the brand's typography depend on hydration having run. It does not
|
|
9
|
+
* always run: a history navigation (back, then forward) can hand the browser a
|
|
10
|
+
* cached copy of the document whose React tree never hydrates, and the page
|
|
11
|
+
* then paints every word in the fallback face — the Jost stack behind
|
|
12
|
+
* `GantModernV2` — with no error anywhere (ZERO-5004). Emitted with the markup,
|
|
13
|
+
* the faces are part of the document itself and survive any restore, and the
|
|
14
|
+
* first paint no longer swaps fonts a frame later.
|
|
15
|
+
*
|
|
16
|
+
* The effect below still runs: it is what applies a font the editor uploads
|
|
17
|
+
* mid-session, and it skips faces this CSS already covers.
|
|
18
|
+
*/
|
|
19
|
+
export const buildCustomFontFaceCss = (
|
|
20
|
+
settings: Record<string, unknown> | null
|
|
21
|
+
): string => {
|
|
22
|
+
const fonts = settings?.customFonts;
|
|
23
|
+
|
|
24
|
+
if (!Array.isArray(fonts)) return '';
|
|
25
|
+
|
|
26
|
+
return fonts
|
|
27
|
+
.map((font) => {
|
|
28
|
+
const name = (font as { name?: unknown })?.name;
|
|
29
|
+
const sources = (font as { sources?: unknown })?.sources;
|
|
30
|
+
|
|
31
|
+
if (typeof name !== 'string' || !name.trim() || !Array.isArray(sources)) {
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const src = sources
|
|
36
|
+
.map((source) => {
|
|
37
|
+
const dataUrl = (source as { dataUrl?: unknown })?.dataUrl;
|
|
38
|
+
const format = (source as { format?: unknown })?.format;
|
|
39
|
+
|
|
40
|
+
if (typeof dataUrl !== 'string' || !dataUrl) return '';
|
|
41
|
+
|
|
42
|
+
return typeof format === 'string' && format
|
|
43
|
+
? `url('${dataUrl}') format('${format}')`
|
|
44
|
+
: `url('${dataUrl}')`;
|
|
45
|
+
})
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join(', ');
|
|
48
|
+
|
|
49
|
+
if (!src) return '';
|
|
50
|
+
|
|
51
|
+
// Editor-controlled values on their way into a <style> tag — the same
|
|
52
|
+
// breakout neutralization the generated section CSS goes through.
|
|
53
|
+
return `@font-face{font-family:'${neutralizeStyleTagBreakout(
|
|
54
|
+
name.trim()
|
|
55
|
+
)}';src:${neutralizeStyleTagBreakout(src)};font-display:swap;}`;
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join('');
|
|
59
|
+
};
|