snappy-prerender 2.7.0 → 2.8.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 +55 -0
- package/README.md +168 -7
- package/assets/snappy-logo.png +0 -0
- package/docs/limitations.md +40 -4
- package/docs/report.md +36 -3
- package/package.json +3 -1
- package/src/cli-options.js +258 -0
- package/src/cli.js +75 -161
- package/src/core/audit/links.js +12 -1
- package/src/core/audit/metadata.js +113 -4
- package/src/core/audit/sitemap.js +1 -1
- package/src/core/clock.js +60 -0
- package/src/core/config-validation.js +557 -0
- package/src/core/config-warnings.js +150 -0
- package/src/core/config.js +132 -203
- package/src/core/crawl.js +76 -14
- package/src/core/csp.js +14 -3
- package/src/core/destination.js +23 -7
- package/src/core/hydration-policy.js +38 -0
- package/src/core/inline-css.js +52 -9
- package/src/core/locale-context.js +63 -0
- package/src/core/locales.js +385 -0
- package/src/core/normalize.js +30 -6
- package/src/core/page-content.js +57 -7
- package/src/core/page-meta.js +18 -4
- package/src/core/preload-links.js +24 -11
- package/src/core/preload-manifest.js +6 -2
- package/src/core/render.js +46 -37
- package/src/core/report.js +9 -1
- package/src/core/route-sources.js +14 -3
- package/src/core/run.js +90 -20
- package/src/core/seo-locale.js +234 -0
- package/src/core/settle.js +2 -4
- package/src/core/sitemap.js +1 -1
- package/src/core/text-separators.js +11 -7
- package/src/core/verify.js +108 -59
- package/src/core/write.js +276 -99
- package/src/head/index.js +103 -5
- package/src/head/manager.js +290 -75
- package/src/head/tags.js +305 -32
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { matchesPattern } from './routes.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the SEO locale recipe for one route. The code comes from the route locale in
|
|
5
|
+
* path mode, from the active locale in single mode, or from seoLocale.code standalone.
|
|
6
|
+
* @param {object} config
|
|
7
|
+
* @param {object | null} locale
|
|
8
|
+
* @param {string} route
|
|
9
|
+
* @returns {object | null}
|
|
10
|
+
*/
|
|
11
|
+
export function resolveSeoLocale(config, locale, route) {
|
|
12
|
+
const seo = config.seoLocale;
|
|
13
|
+
if (!seo) return null;
|
|
14
|
+
if (!(seo.routes ?? []).some((pattern) => matchesPattern(route, pattern))) return null;
|
|
15
|
+
const code =
|
|
16
|
+
config.locales?.mode === 'path'
|
|
17
|
+
? locale?.code
|
|
18
|
+
: (config.locales?.active ?? seo.code ?? locale?.code);
|
|
19
|
+
if (!code) return null;
|
|
20
|
+
return { ...seo, code, value: seo.value ?? code };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Seeds local storage before the app bundle runs. The init script runs on the page's
|
|
25
|
+
* origin, which is what makes it work with the ephemeral prerender server.
|
|
26
|
+
* @param {import('playwright-core').Page} page
|
|
27
|
+
* @param {object} seo
|
|
28
|
+
*/
|
|
29
|
+
export async function seedPageStorage(page, seo) {
|
|
30
|
+
if (seo.source !== 'localStorage' && seo.source !== 'both') return;
|
|
31
|
+
await seedLocalStorage(page, { [seo.localStorageKey]: seo.value }, seo.overwrite);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seeds a fixed set of local storage entries before the app boots.
|
|
36
|
+
* @param {import('playwright-core').Page} page
|
|
37
|
+
* @param {Record<string, string>} entries
|
|
38
|
+
* @param {boolean} overwrite
|
|
39
|
+
*/
|
|
40
|
+
export async function seedLocalStorage(page, entries, overwrite = true) {
|
|
41
|
+
if (!entries || Object.keys(entries).length === 0) return;
|
|
42
|
+
await page.addInitScript(
|
|
43
|
+
({ values, replace }) => {
|
|
44
|
+
try {
|
|
45
|
+
for (const [key, value] of Object.entries(values)) {
|
|
46
|
+
if (replace || window.localStorage.getItem(key) === null) {
|
|
47
|
+
window.localStorage.setItem(key, value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Local storage can be unavailable in private contexts; the app still renders its default.
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ values: entries, replace: overwrite },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Seeds the locale cookie in the browser context, using the current origin when no
|
|
60
|
+
* explicit domain is configured.
|
|
61
|
+
* @param {import('playwright-core').BrowserContext} context
|
|
62
|
+
* @param {string} origin
|
|
63
|
+
* @param {object} seo
|
|
64
|
+
*/
|
|
65
|
+
export async function seedContextCookie(context, origin, seo) {
|
|
66
|
+
if (seo.source !== 'cookie' && seo.source !== 'both') return;
|
|
67
|
+
if (!seo.overwrite) {
|
|
68
|
+
const existing = await context.cookies();
|
|
69
|
+
if (existing.some((cookie) => cookie.name === seo.cookie.name)) return;
|
|
70
|
+
}
|
|
71
|
+
await seedCookies(context, origin, [
|
|
72
|
+
{
|
|
73
|
+
...seo.cookie,
|
|
74
|
+
value: seo.value,
|
|
75
|
+
httpOnly: false,
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Adds cookies with the same attributes to the render and verification contexts.
|
|
82
|
+
* @param {import('playwright-core').BrowserContext} context
|
|
83
|
+
* @param {string} origin
|
|
84
|
+
* @param {Array<object>} cookies
|
|
85
|
+
*/
|
|
86
|
+
export async function seedCookies(context, origin, cookies = []) {
|
|
87
|
+
if (!Array.isArray(cookies) || cookies.length === 0) return;
|
|
88
|
+
const entries = cookies.map((cookie) => {
|
|
89
|
+
const secure = cookie.secure === true && !origin.startsWith('http://');
|
|
90
|
+
const sameSite = normaliseSameSite(cookie.sameSite);
|
|
91
|
+
const entry = {
|
|
92
|
+
name: cookie.name,
|
|
93
|
+
value: String(cookie.value),
|
|
94
|
+
httpOnly: cookie.httpOnly === true,
|
|
95
|
+
secure,
|
|
96
|
+
// Browsers reject SameSite=None without Secure; keep local HTTP previews usable.
|
|
97
|
+
sameSite: sameSite === 'None' && !secure ? 'Lax' : sameSite,
|
|
98
|
+
expires:
|
|
99
|
+
cookie.maxAge !== null && cookie.maxAge !== undefined
|
|
100
|
+
? Math.floor(Date.now() / 1000) + cookie.maxAge
|
|
101
|
+
: -1,
|
|
102
|
+
};
|
|
103
|
+
if (cookie.domain) {
|
|
104
|
+
entry.domain = cookie.domain;
|
|
105
|
+
entry.path = cookie.path ?? '/';
|
|
106
|
+
} else {
|
|
107
|
+
entry.url = origin;
|
|
108
|
+
}
|
|
109
|
+
return entry;
|
|
110
|
+
});
|
|
111
|
+
await context.addCookies(entries);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Builds the small bootstrap script written into localized pages. Values are serialised as
|
|
116
|
+
* strings and cookie values are percent-encoded; no configuration value is interpolated
|
|
117
|
+
* as raw JavaScript.
|
|
118
|
+
* @param {object} seo
|
|
119
|
+
* @returns {string}
|
|
120
|
+
*/
|
|
121
|
+
export function buildSeoLocaleScript(seo) {
|
|
122
|
+
const key = JSON.stringify(seo.localStorageKey);
|
|
123
|
+
const value = JSON.stringify(seo.value);
|
|
124
|
+
const cookieName = JSON.stringify(seo.cookie.name);
|
|
125
|
+
const cookieAttributes =
|
|
126
|
+
`; path=${seo.cookie.path ?? '/'}` +
|
|
127
|
+
(seo.cookie.domain ? `; domain=${seo.cookie.domain}` : '') +
|
|
128
|
+
(seo.cookie.maxAge !== null && seo.cookie.maxAge !== undefined
|
|
129
|
+
? `; max-age=${seo.cookie.maxAge}`
|
|
130
|
+
: '') +
|
|
131
|
+
`; SameSite=${normaliseSameSite(seo.cookie.sameSite)}` +
|
|
132
|
+
(seo.cookie.secure ? '; Secure' : '');
|
|
133
|
+
const cookieExpression = `${cookieName} + "=" + encodeURIComponent(${value}) + ${JSON.stringify(cookieAttributes)}`;
|
|
134
|
+
const localCondition = seo.overwrite ? 'true' : `window.localStorage.getItem(${key}) === null`;
|
|
135
|
+
const parts = ['(function () {'];
|
|
136
|
+
if (seo.source === 'localStorage' || seo.source === 'both') {
|
|
137
|
+
parts.push(
|
|
138
|
+
' try {',
|
|
139
|
+
` if (${localCondition}) {`,
|
|
140
|
+
` window.localStorage.setItem(${key}, ${value});`,
|
|
141
|
+
' }',
|
|
142
|
+
' } catch (error) {}',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (seo.source === 'cookie' || seo.source === 'both') {
|
|
146
|
+
parts.push(
|
|
147
|
+
' try {',
|
|
148
|
+
` var name = ${cookieName};`,
|
|
149
|
+
' var has = document.cookie.split("; ").some(function (part) {',
|
|
150
|
+
' return part.split("=")[0] === name;',
|
|
151
|
+
' });',
|
|
152
|
+
` if (${seo.overwrite ? 'true' : '!has'}) {`,
|
|
153
|
+
` document.cookie = ${cookieExpression};`,
|
|
154
|
+
' }',
|
|
155
|
+
' } catch (error) {}',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
parts.push('})();');
|
|
159
|
+
return parts.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Inserts the bootstrap before the first head script, or at the end of the head when the
|
|
164
|
+
* document has no script. A generated marker makes reruns replace the same script.
|
|
165
|
+
* @param {object} document parse5 document
|
|
166
|
+
* @param {string} script
|
|
167
|
+
* @param {string | null} externalHref
|
|
168
|
+
*/
|
|
169
|
+
export function injectSeoLocaleScript(document, script, externalHref = null) {
|
|
170
|
+
const head = findHead(document);
|
|
171
|
+
if (!head) return;
|
|
172
|
+
head.childNodes = (head.childNodes ?? []).filter(
|
|
173
|
+
(node) => !node.attrs?.some((entry) => entry.name === 'data-snappy-seo-locale'),
|
|
174
|
+
);
|
|
175
|
+
const node = externalHref
|
|
176
|
+
? {
|
|
177
|
+
nodeName: 'script',
|
|
178
|
+
tagName: 'script',
|
|
179
|
+
attrs: [
|
|
180
|
+
{ name: 'src', value: externalHref },
|
|
181
|
+
{ name: 'data-snappy-seo-locale', value: '' },
|
|
182
|
+
],
|
|
183
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
184
|
+
childNodes: [],
|
|
185
|
+
parentNode: null,
|
|
186
|
+
}
|
|
187
|
+
: {
|
|
188
|
+
nodeName: 'script',
|
|
189
|
+
tagName: 'script',
|
|
190
|
+
attrs: [{ name: 'data-snappy-seo-locale', value: '' }],
|
|
191
|
+
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
|
192
|
+
childNodes: [{ nodeName: '#text', value: script, parentNode: null }],
|
|
193
|
+
parentNode: null,
|
|
194
|
+
};
|
|
195
|
+
const scriptIndex = (head.childNodes ?? []).findIndex((child) => child.tagName === 'script');
|
|
196
|
+
if (scriptIndex === -1) head.childNodes.push(node);
|
|
197
|
+
else head.childNodes.splice(scriptIndex, 0, node);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Returns the current head language and direction controls for metadata injection.
|
|
202
|
+
* @param {object | null} seo
|
|
203
|
+
* @returns {{ htmlLang: string | boolean, dir: string | null, forceHtmlLang: boolean }}
|
|
204
|
+
*/
|
|
205
|
+
export function seoLocaleMetadataOptions(seo) {
|
|
206
|
+
if (!seo) return { htmlLang: true, dir: null, forceHtmlLang: false };
|
|
207
|
+
return {
|
|
208
|
+
htmlLang: seo.htmlLang,
|
|
209
|
+
dir: seo.dir,
|
|
210
|
+
forceHtmlLang: seo.forceHtmlLang === true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Normalises a cookie SameSite value to the form Playwright and document.cookie expect.
|
|
216
|
+
* @param {string | undefined} value
|
|
217
|
+
* @returns {'Strict' | 'Lax' | 'None'}
|
|
218
|
+
*/
|
|
219
|
+
function normaliseSameSite(value) {
|
|
220
|
+
const text = String(value ?? 'Lax').toLowerCase();
|
|
221
|
+
if (text === 'strict') return 'Strict';
|
|
222
|
+
if (text === 'none') return 'None';
|
|
223
|
+
return 'Lax';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Finds the document head in a parsed document.
|
|
228
|
+
* @param {object} document
|
|
229
|
+
* @returns {object | null}
|
|
230
|
+
*/
|
|
231
|
+
function findHead(document) {
|
|
232
|
+
const html = (document.childNodes ?? []).find((node) => node.tagName === 'html');
|
|
233
|
+
return (html?.childNodes ?? []).find((node) => node.tagName === 'head') ?? null;
|
|
234
|
+
}
|
package/src/core/settle.js
CHANGED
|
@@ -112,15 +112,13 @@ export async function settlePage(page, config, tracker) {
|
|
|
112
112
|
* normalisation.
|
|
113
113
|
*/
|
|
114
114
|
export async function freezeAnimations(page) {
|
|
115
|
-
await page.evaluate((css) => {
|
|
115
|
+
await page.evaluate(async (css) => {
|
|
116
116
|
const style = document.createElement('style');
|
|
117
117
|
style.id = 'snappy-freeze';
|
|
118
118
|
style.textContent = css;
|
|
119
119
|
document.head.appendChild(style);
|
|
120
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
120
121
|
}, FREEZE_CSS);
|
|
121
|
-
await page.evaluate(
|
|
122
|
-
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
|
123
|
-
);
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
/**
|
package/src/core/sitemap.js
CHANGED
|
@@ -26,7 +26,7 @@ export function buildSitemap({ files, config, maxUrls = SITEMAP_MAX_URLS }) {
|
|
|
26
26
|
})
|
|
27
27
|
: publicPath(file.file);
|
|
28
28
|
entries.push({
|
|
29
|
-
loc: `${siteUrl}${toPublicPath(path, config.base)}`,
|
|
29
|
+
loc: file.localeUrl ?? `${siteUrl}${toPublicPath(path, config.base)}`,
|
|
30
30
|
alternates: normaliseAlternates(file.alternates ?? [], siteUrl, config.base),
|
|
31
31
|
images: config.imageSitemap ? normaliseImages(file.images ?? [], siteUrl, config.base) : [],
|
|
32
32
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* rendering emits the same separators
|
|
2
|
+
* Final page snapshot: inserts comment separators between adjacent text nodes and
|
|
3
|
+
* returns the LCP URL and anchor hrefs in the same browser round trip. HTML cannot
|
|
4
|
+
* express two neighbouring text nodes, so serialising a client-rendered DOM merges
|
|
5
|
+
* them and hydration then finds one node where the client expects two. React's own
|
|
6
|
+
* server rendering emits the same separators.
|
|
7
7
|
*/
|
|
8
|
-
export async function
|
|
9
|
-
|
|
8
|
+
export async function readRenderSnapshot(page) {
|
|
9
|
+
return page.evaluate(() => {
|
|
10
10
|
// Elements whose children are raw text, where a comment would corrupt the content.
|
|
11
11
|
const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE']);
|
|
12
12
|
|
|
@@ -26,5 +26,9 @@ export async function separateTextNodes(page) {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
separate(document.documentElement);
|
|
29
|
+
return {
|
|
30
|
+
lcpUrl: window.__snappyLcp?.at(-1)?.url ?? null,
|
|
31
|
+
hrefs: Array.from(document.querySelectorAll('a[href]'), (anchor) => anchor.href),
|
|
32
|
+
};
|
|
29
33
|
});
|
|
30
34
|
}
|
package/src/core/verify.js
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { blockThirdPartyRequests } from './blocking.js';
|
|
4
|
+
import { installClock, installClockScripts } from './clock.js';
|
|
4
5
|
import { DEFAULTS } from './config.js';
|
|
5
6
|
import { frameworkFromMarkers, isHydrationError } from './frameworks.js';
|
|
6
7
|
import { compareContainers } from './hydration-diff.js';
|
|
8
|
+
import { isHydrationAllowed, resolveHydrationPolicy } from './hydration-policy.js';
|
|
9
|
+
import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
|
|
10
|
+
import { activeLocale } from './locales.js';
|
|
7
11
|
import { routeToFile } from './output.js';
|
|
8
|
-
import {
|
|
12
|
+
import { CONTAINER_SELECTOR, readBootState } from './page-content.js';
|
|
9
13
|
import { resolveRouteConfig } from './route-config.js';
|
|
10
14
|
import { normaliseRoute, toServerUrl } from './routes.js';
|
|
11
|
-
import { mapLimit } from './scheduler.js';
|
|
12
15
|
import { settlePage, trackNetwork } from './settle.js';
|
|
13
16
|
|
|
14
|
-
// Containers used by the common setups, so the probe can find the prerendered
|
|
15
|
-
// subtree without configuration.
|
|
16
|
-
const CONTAINER_SELECTOR = '#root, #app, #__next';
|
|
17
|
-
|
|
18
17
|
/**
|
|
19
18
|
* Page-side probe installed before navigation. It remembers the first prerendered
|
|
20
19
|
* child of the app container, which is what lets the verifier tell hydration from a
|
|
@@ -57,9 +56,53 @@ export function classifyBoot({ markupPreserved, frameworkMounted }) {
|
|
|
57
56
|
* hydration errors plus how each route booted. This is the pass that catches markup
|
|
58
57
|
* that only breaks once the app re-attaches, and apps that never attach to it at all.
|
|
59
58
|
*/
|
|
60
|
-
export async function verifyRoutes({
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
export async function verifyRoutes({
|
|
60
|
+
browser,
|
|
61
|
+
origin,
|
|
62
|
+
routes,
|
|
63
|
+
config,
|
|
64
|
+
outputDir = null,
|
|
65
|
+
routeLocales = new Map(),
|
|
66
|
+
}) {
|
|
67
|
+
const policySkipped = routes.filter((route) => resolveHydrationPolicy(config, route) === 'skip');
|
|
68
|
+
const policySkippedSet = new Set(policySkipped);
|
|
69
|
+
const toVerify = routes.filter((route) => !policySkippedSet.has(route));
|
|
70
|
+
const results = new Array(toVerify.length).fill(undefined);
|
|
71
|
+
const errors = [];
|
|
72
|
+
let cursor = 0;
|
|
73
|
+
const canReuse = config.reuseVerificationContext && !config.locales && !config.seoLocale;
|
|
74
|
+
const workerCount = Math.max(1, Math.min(config.concurrency, toVerify.length || 1));
|
|
75
|
+
await Promise.all(
|
|
76
|
+
Array.from({ length: workerCount }, async () => {
|
|
77
|
+
let sharedContext = null;
|
|
78
|
+
try {
|
|
79
|
+
if (canReuse && toVerify.length > 0) {
|
|
80
|
+
sharedContext = await createContext(browser, config, activeLocale(config.locales));
|
|
81
|
+
}
|
|
82
|
+
while (cursor < toVerify.length) {
|
|
83
|
+
const index = cursor;
|
|
84
|
+
cursor += 1;
|
|
85
|
+
const route = toVerify[index];
|
|
86
|
+
try {
|
|
87
|
+
results[index] = await verifyRoute({
|
|
88
|
+
browser,
|
|
89
|
+
origin,
|
|
90
|
+
route,
|
|
91
|
+
config: resolveRouteConfig(config, route),
|
|
92
|
+
outputDir,
|
|
93
|
+
context: sharedContext,
|
|
94
|
+
locale:
|
|
95
|
+
routeLocales.get(route)?.locale ??
|
|
96
|
+
(config.locales?.mode === 'single' ? activeLocale(config.locales) : null),
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
errors.push({ item: route, error });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
if (sharedContext) await sharedContext.close();
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
63
106
|
);
|
|
64
107
|
const verified = results.filter(Boolean);
|
|
65
108
|
const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
|
|
@@ -73,26 +116,51 @@ export async function verifyRoutes({ browser, origin, routes, config, outputDir
|
|
|
73
116
|
routes: verified,
|
|
74
117
|
modes,
|
|
75
118
|
frameworks,
|
|
119
|
+
allowed: verified.filter((entry) => entry.allowed).length,
|
|
120
|
+
allowedRerender: verified.filter((entry) => entry.allowed && entry.mode === 're-rendered')
|
|
121
|
+
.length,
|
|
122
|
+
skippedRoutes: policySkipped,
|
|
76
123
|
ok: verified.every((entry) => entry.ok) && errors.length === 0,
|
|
77
124
|
errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
|
|
78
125
|
};
|
|
79
126
|
}
|
|
80
127
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*/
|
|
85
|
-
async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
86
|
-
const context = await browser.newContext({
|
|
128
|
+
// Creates the browser context a verification worker reuses across routes.
|
|
129
|
+
function createContext(browser, config, locale = null) {
|
|
130
|
+
return browser.newContext({
|
|
87
131
|
viewport: config.viewport,
|
|
88
|
-
storageState: config
|
|
132
|
+
storageState: localeStorageState(config, locale),
|
|
89
133
|
userAgent:
|
|
90
134
|
config.userAgent === DEFAULTS.userAgent ? undefined : (config.userAgent ?? undefined),
|
|
91
135
|
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
|
|
92
136
|
reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
|
|
93
137
|
serviceWorkers: 'block',
|
|
138
|
+
...localeContextOptions(config, locale),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verifies one route against the same request rules as the render pass, so what is
|
|
144
|
+
* verified matches what was rendered. A supplied context is reused by the worker; the
|
|
145
|
+
* page is always fresh.
|
|
146
|
+
*/
|
|
147
|
+
async function verifyRoute({
|
|
148
|
+
browser,
|
|
149
|
+
origin,
|
|
150
|
+
route,
|
|
151
|
+
config,
|
|
152
|
+
outputDir,
|
|
153
|
+
context = null,
|
|
154
|
+
locale = null,
|
|
155
|
+
}) {
|
|
156
|
+
const ownsContext = context === null;
|
|
157
|
+
const activeContext = context ?? (await createContext(browser, config, locale));
|
|
158
|
+
const page = await activeContext.newPage();
|
|
159
|
+
await installClock(page, config.fixedTime ?? null);
|
|
160
|
+
await installClockScripts(page, {
|
|
161
|
+
fixedTime: config.fixedTime ?? null,
|
|
162
|
+
randomSeed: config.randomSeed ?? null,
|
|
94
163
|
});
|
|
95
|
-
const page = await context.newPage();
|
|
96
164
|
const consoleMessages = [];
|
|
97
165
|
const pageMessages = [];
|
|
98
166
|
page.on('console', (message) => {
|
|
@@ -105,6 +173,15 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
|
105
173
|
if (config.blockThirdParty) {
|
|
106
174
|
await blockThirdPartyRequests(page, origin, config.allowedHosts);
|
|
107
175
|
}
|
|
176
|
+
await seedLocaleContext({
|
|
177
|
+
page,
|
|
178
|
+
context: activeContext,
|
|
179
|
+
origin,
|
|
180
|
+
config,
|
|
181
|
+
route,
|
|
182
|
+
locale,
|
|
183
|
+
phase: 'verify',
|
|
184
|
+
});
|
|
108
185
|
await page.addInitScript(bootProbe);
|
|
109
186
|
const response = await page.goto(toServerUrl(route, config.base, origin), {
|
|
110
187
|
waitUntil: 'domcontentloaded',
|
|
@@ -112,47 +189,12 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
|
112
189
|
});
|
|
113
190
|
const status = response?.status() ?? null;
|
|
114
191
|
await settlePage(page, config, tracker);
|
|
115
|
-
const empty = await
|
|
116
|
-
const statusCode = await page.evaluate(() => {
|
|
117
|
-
const marker = document.querySelector('meta[name="prerender-status-code"]');
|
|
118
|
-
if (!marker) return null;
|
|
119
|
-
const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
|
|
120
|
-
return Number.isFinite(value) ? value : null;
|
|
121
|
-
});
|
|
192
|
+
const { empty, statusCode, markers, markupPreserved } = await readBootState(page);
|
|
122
193
|
const notFoundRoute = config.notFoundRoute ? normaliseRoute(config.notFoundRoute) : null;
|
|
123
194
|
const isNotFoundRoute = notFoundRoute !== null && normaliseRoute(route) === notFoundRoute;
|
|
124
195
|
const notFound = statusCode === 404 || isNotFoundRoute;
|
|
125
196
|
|
|
126
|
-
const
|
|
127
|
-
const probe = window.__snappyBoot ?? {};
|
|
128
|
-
const container =
|
|
129
|
-
document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
|
|
130
|
-
const markers = container
|
|
131
|
-
? {
|
|
132
|
-
react: Object.keys(container).some(
|
|
133
|
-
(key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
|
|
134
|
-
),
|
|
135
|
-
vue: Boolean(
|
|
136
|
-
container.__vue_app__ ||
|
|
137
|
-
container.__vueParentComponent ||
|
|
138
|
-
container._vnode ||
|
|
139
|
-
container.__vue__,
|
|
140
|
-
),
|
|
141
|
-
preact: Boolean(
|
|
142
|
-
container.__k ||
|
|
143
|
-
container._children ||
|
|
144
|
-
container.firstElementChild?.__k ||
|
|
145
|
-
container.firstElementChild?._children,
|
|
146
|
-
),
|
|
147
|
-
svelte: Boolean(
|
|
148
|
-
container.__svelte_meta || container.querySelector?.('[data-svelte-h]'),
|
|
149
|
-
),
|
|
150
|
-
}
|
|
151
|
-
: {};
|
|
152
|
-
const child = probe.prerenderedChild ?? null;
|
|
153
|
-
return { markers, markupPreserved: child ? child.isConnected : null };
|
|
154
|
-
}, CONTAINER_SELECTOR);
|
|
155
|
-
const detected = frameworkFromMarkers(boot.markers, config.framework);
|
|
197
|
+
const detected = frameworkFromMarkers(markers, config.framework);
|
|
156
198
|
// Classify only after the framework is known, so an explicit `framework` choice and
|
|
157
199
|
// the detected framework select the matching message patterns.
|
|
158
200
|
const hydrationErrors = [];
|
|
@@ -167,7 +209,7 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
|
167
209
|
else pageErrors.push(message);
|
|
168
210
|
}
|
|
169
211
|
const mode = classifyBoot({
|
|
170
|
-
markupPreserved
|
|
212
|
+
markupPreserved,
|
|
171
213
|
frameworkMounted: detected.mounted,
|
|
172
214
|
});
|
|
173
215
|
let firstMismatch = null;
|
|
@@ -180,6 +222,9 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
|
180
222
|
const expectedHtml = await fs.readFile(file, 'utf8').catch(() => null);
|
|
181
223
|
if (liveHtml && expectedHtml) firstMismatch = compareContainers(expectedHtml, liveHtml);
|
|
182
224
|
}
|
|
225
|
+
const hydrationPolicy = resolveHydrationPolicy(config, route);
|
|
226
|
+
const allowed = isHydrationAllowed(hydrationPolicy, { mode, hydrationErrors });
|
|
227
|
+
const effectiveHydrationErrors = allowed ? [] : hydrationErrors;
|
|
183
228
|
|
|
184
229
|
return {
|
|
185
230
|
route,
|
|
@@ -188,19 +233,23 @@ async function verifyRoute({ browser, origin, route, config, outputDir }) {
|
|
|
188
233
|
notFound,
|
|
189
234
|
framework: detected.name,
|
|
190
235
|
empty,
|
|
191
|
-
hydrationErrors,
|
|
236
|
+
hydrationErrors: effectiveHydrationErrors,
|
|
237
|
+
ignoredHydrationErrors: allowed ? hydrationErrors : [],
|
|
238
|
+
hydrationPolicy,
|
|
239
|
+
allowed,
|
|
192
240
|
consoleErrors,
|
|
193
241
|
pageErrors,
|
|
194
242
|
ok:
|
|
195
|
-
|
|
243
|
+
effectiveHydrationErrors.length === 0 &&
|
|
196
244
|
(status === null || status < 400 || (isNotFoundRoute && status === 404)) &&
|
|
197
245
|
!empty,
|
|
198
246
|
mode,
|
|
199
247
|
firstMismatch,
|
|
200
|
-
markupPreserved
|
|
248
|
+
markupPreserved,
|
|
201
249
|
};
|
|
202
250
|
} finally {
|
|
203
251
|
tracker.stop();
|
|
204
|
-
await
|
|
252
|
+
await page.close();
|
|
253
|
+
if (ownsContext) await activeContext.close();
|
|
205
254
|
}
|
|
206
255
|
}
|