snappy-prerender 2.6.0-rc.2

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Captures styles that exist only in the CSS object model. Runtimes that use
3
+ * CSSStyleSheet.insertRule (emotion and styled-components in their production
4
+ * "speedy" modes, JSS, and others) leave the style element empty in the DOM, so
5
+ * serialising the page would drop every rule. This copies those rules into the
6
+ * element text, and folds document-level constructable stylesheets into a style tag.
7
+ * A no-op for ordinary style elements and for pages without runtime styles.
8
+ */
9
+ export async function captureRuntimeStyles(page) {
10
+ await page.evaluate(() => {
11
+ // Reads the rules a style element holds in the CSSOM, tolerating odd sheets.
12
+ const rulesOf = (sheet) => {
13
+ try {
14
+ return Array.from(sheet.cssRules ?? []);
15
+ } catch {
16
+ return [];
17
+ }
18
+ };
19
+
20
+ for (const style of document.querySelectorAll('style')) {
21
+ if (style.textContent.trim().length > 0) continue;
22
+ if (!style.sheet) continue;
23
+ const css = rulesOf(style.sheet)
24
+ .map((rule) => rule.cssText)
25
+ .join('\n');
26
+ if (css.length > 0) style.textContent = css;
27
+ }
28
+
29
+ const adopted = Array.from(document.adoptedStyleSheets ?? []);
30
+ if (adopted.length === 0) return;
31
+ const css = adopted
32
+ .flatMap((sheet) => rulesOf(sheet))
33
+ .map((rule) => rule.cssText)
34
+ .join('\n');
35
+ if (css.trim().length === 0) return;
36
+
37
+ const style = document.createElement('style');
38
+ style.setAttribute('data-snappy-adopted', '');
39
+ style.textContent = css;
40
+ document.head.appendChild(style);
41
+ });
42
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Dynamic work pool used for route rendering. Tasks may enqueue more tasks while
3
+ * running, which is what lets the crawler discover links from rendered pages.
4
+ * Workers exit only when the queue is empty and nothing is in flight, so `run()`
5
+ * resolves after the last discovered route finishes.
6
+ */
7
+ export function createPool({ concurrency, handler }) {
8
+ const queue = [];
9
+ const waiters = [];
10
+ const errors = [];
11
+ let outstanding = 0;
12
+ let finished = false;
13
+
14
+ // Resolves every worker parked on an empty queue so they can re-check for work.
15
+ function wakeAll() {
16
+ while (waiters.length > 0) waiters.shift()();
17
+ }
18
+
19
+ // Worker loop: drains the queue, parks when empty, exits once nothing is left.
20
+ async function loop() {
21
+ while (true) {
22
+ if (queue.length === 0) {
23
+ if (outstanding === 0) return;
24
+ await new Promise((resolve) => waiters.push(resolve));
25
+ continue;
26
+ }
27
+ const item = queue.shift();
28
+ try {
29
+ await handler(item);
30
+ } catch (error) {
31
+ errors.push({ item, error });
32
+ } finally {
33
+ outstanding -= 1;
34
+ if (outstanding === 0) wakeAll();
35
+ }
36
+ }
37
+ }
38
+
39
+ return {
40
+ push(items) {
41
+ if (finished) {
42
+ throw new Error('cannot push work after the pool has finished; push before run() resolves');
43
+ }
44
+ for (const item of items) {
45
+ queue.push(item);
46
+ outstanding += 1;
47
+ }
48
+ wakeAll();
49
+ },
50
+ async run() {
51
+ await Promise.all(Array.from({ length: concurrency }, () => loop()));
52
+ finished = true;
53
+ return { errors };
54
+ },
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Maps a fixed list of items through an async worker with bounded concurrency and
60
+ * per-item error capture, so one failed item never aborts the rest.
61
+ */
62
+ export async function mapLimit(items, concurrency, worker) {
63
+ const results = new Array(items.length).fill(undefined);
64
+ const errors = [];
65
+ let cursor = 0;
66
+
67
+ // Worker loop: claims indices from the shared cursor until the list is consumed.
68
+ async function loop() {
69
+ while (cursor < items.length) {
70
+ const index = cursor;
71
+ cursor += 1;
72
+ try {
73
+ results[index] = await worker(items[index]);
74
+ } catch (error) {
75
+ errors.push({ item: items[index], error });
76
+ }
77
+ }
78
+ }
79
+
80
+ const workerCount = Math.max(1, Math.min(concurrency, items.length || 1));
81
+ await Promise.all(Array.from({ length: workerCount }, () => loop()));
82
+ return { results, errors };
83
+ }
@@ -0,0 +1,216 @@
1
+ import { spawn } from 'node:child_process';
2
+ import http from 'node:http';
3
+ import sirv from 'sirv';
4
+
5
+ /**
6
+ * Serves the built output for rendering and verification. Runs in sirv dev mode so
7
+ * HTML files written during the run are picked up by the verification pass, and on
8
+ * an ephemeral loopback port so parallel runs never collide.
9
+ */
10
+ export async function startStaticServer({ dir, base }) {
11
+ const handler = sirv(dir, { dev: true, etag: true, single: true });
12
+ const server = http.createServer((request, response) => {
13
+ if (base !== '/') {
14
+ const stripped = stripBase(request.url, base);
15
+ if (stripped === null) {
16
+ response.statusCode = 404;
17
+ response.end('Not found');
18
+ return;
19
+ }
20
+ request.url = stripped;
21
+ }
22
+ handler(request, response);
23
+ });
24
+
25
+ await new Promise((resolve, reject) => {
26
+ server.once('error', reject);
27
+ server.listen(0, '127.0.0.1', resolve);
28
+ });
29
+
30
+ const { port } = server.address();
31
+ return {
32
+ origin: `http://127.0.0.1:${port}`,
33
+ async close() {
34
+ server.closeAllConnections();
35
+ await new Promise((resolve) => server.close(resolve));
36
+ },
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Removes the configured base prefix from a request URL, keeping any query string
42
+ * intact. Returns null when the request falls outside the base path.
43
+ */
44
+ function stripBase(url, base) {
45
+ const prefix = base.slice(0, -1);
46
+ const [pathname, search] = url.split('?');
47
+ const query = search === undefined ? '' : `?${search}`;
48
+ if (pathname === prefix) return `/${query}`;
49
+ if (pathname.startsWith(`${prefix}/`)) return `${pathname.slice(prefix.length)}${query}`;
50
+ return null;
51
+ }
52
+
53
+ /**
54
+ * Runs a user-provided server command (for apps whose routes need a live API or
55
+ * runtime) and waits until the given URL answers before rendering starts.
56
+ */
57
+ export async function startCommandServer({ command, url, timeout, shutdownTimeout }) {
58
+ const child = spawn(command, {
59
+ shell: true,
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ detached: process.platform !== 'win32',
62
+ });
63
+
64
+ const output = [];
65
+ const capture = (chunk) => {
66
+ output.push(chunk.toString());
67
+ if (output.length > 50) output.shift();
68
+ };
69
+ child.stdout.on('data', capture);
70
+ child.stderr.on('data', capture);
71
+
72
+ let spawnError = null;
73
+ let exited = false;
74
+ child.once('error', (error) => {
75
+ spawnError = error;
76
+ });
77
+ child.once('exit', () => {
78
+ exited = true;
79
+ });
80
+
81
+ try {
82
+ await waitForUrl(url, timeout, () => {
83
+ if (spawnError) return spawnError;
84
+ return exited ? new Error('process exited before responding') : null;
85
+ });
86
+ } catch (error) {
87
+ await stopProcessTree(child, shutdownTimeout);
88
+ const tail = output.join('').trim();
89
+ throw new Error(
90
+ `serveCmd did not become reachable at ${url}: ${error.message}${tail ? `\n${tail}` : ''}`,
91
+ );
92
+ }
93
+
94
+ return {
95
+ origin: new URL(url).origin,
96
+ async close() {
97
+ return stopProcessTree(child, shutdownTimeout);
98
+ },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Polls the target URL until it answers with a success status, the process dies, or
104
+ * the timeout expires. `failure()` reports a terminal reason when one exists.
105
+ */
106
+ async function waitForUrl(url, timeout, failure) {
107
+ const startedAt = Date.now();
108
+ while (true) {
109
+ const reason = failure();
110
+ if (reason) throw reason;
111
+ try {
112
+ const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
113
+ if (response.ok) return;
114
+ } catch {
115
+ // Server not up yet; retry until the deadline.
116
+ }
117
+ if (Date.now() - startedAt > timeout) throw new Error('timed out waiting for a response');
118
+ await new Promise((resolve) => setTimeout(resolve, 200));
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Terminates a spawned server process tree. Windows uses taskkill /T /F because
124
+ * detached process groups are not available there. On POSIX the whole process group
125
+ * is signalled so shell wrappers and grandchildren die too, escalating to SIGKILL
126
+ * after the grace period. The child must have been spawned with `detached: true` on
127
+ * POSIX so it leads its own group; liveness is judged by the group, never by the
128
+ * direct child's exit, because a shell can die while the server it launched lives on.
129
+ * Returns false when the tree is still detectable at the end of the deadline.
130
+ */
131
+ export async function stopProcessTree(child, shutdownTimeout, io = defaultProcessIo) {
132
+ if (!child.pid) return true;
133
+
134
+ if (io.platform === 'win32') {
135
+ if (io.hasExited(child)) return true;
136
+ await io.runTaskkill(child.pid);
137
+ return waitForChildExit(child, shutdownTimeout, io);
138
+ }
139
+
140
+ if (!io.isGroupAlive(child.pid)) return true;
141
+
142
+ io.signalGroup(child.pid, 'SIGTERM');
143
+ if (await waitForGroupExit(child.pid, shutdownTimeout, io)) return true;
144
+
145
+ io.signalGroup(child.pid, 'SIGKILL');
146
+ return waitForGroupExit(child.pid, shutdownTimeout, io);
147
+ }
148
+
149
+ // Platform primitives used by stopProcessTree. Injectable so the POSIX escalation
150
+ // path can be exercised from any host, including Windows.
151
+ const defaultProcessIo = {
152
+ platform: process.platform,
153
+ hasExited,
154
+ isGroupAlive,
155
+ signalGroup,
156
+ runTaskkill,
157
+ };
158
+
159
+ // Reports whether the spawned child has already been reaped.
160
+ function hasExited(child) {
161
+ return child.exitCode !== null || child.signalCode !== null;
162
+ }
163
+
164
+ // Reports whether any process in the child's process group still exists.
165
+ function isGroupAlive(pid) {
166
+ try {
167
+ process.kill(-pid, 0);
168
+ return true;
169
+ } catch (error) {
170
+ return error.code === 'EPERM';
171
+ }
172
+ }
173
+
174
+ // Signals the whole process group, falling back to the direct child when the group
175
+ // is already gone or cannot be signalled.
176
+ function signalGroup(pid, signal) {
177
+ try {
178
+ process.kill(-pid, signal);
179
+ } catch {
180
+ try {
181
+ process.kill(pid, signal);
182
+ } catch {
183
+ // Nothing left to signal.
184
+ }
185
+ }
186
+ }
187
+
188
+ // Polls until the direct child is reaped, covering the gap between taskkill
189
+ // returning and the exit event being observed.
190
+ async function waitForChildExit(child, timeout, io) {
191
+ const startedAt = Date.now();
192
+ while (!io.hasExited(child)) {
193
+ if (Date.now() - startedAt > timeout) return false;
194
+ await new Promise((resolve) => setTimeout(resolve, 50));
195
+ }
196
+ return true;
197
+ }
198
+
199
+ // Polls until the process group disappears or the deadline passes.
200
+ async function waitForGroupExit(pid, timeout, io) {
201
+ const startedAt = Date.now();
202
+ while (io.isGroupAlive(pid)) {
203
+ if (Date.now() - startedAt > timeout) return false;
204
+ await new Promise((resolve) => setTimeout(resolve, 50));
205
+ }
206
+ return true;
207
+ }
208
+
209
+ // Force-kills a process tree on Windows, resolving regardless of taskkill's outcome.
210
+ function runTaskkill(pid) {
211
+ return new Promise((resolve) => {
212
+ const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
213
+ killer.once('exit', resolve);
214
+ killer.once('error', resolve);
215
+ });
216
+ }
@@ -0,0 +1,141 @@
1
+ const FREEZE_CSS = [
2
+ '*, *::before, *::after {',
3
+ ' animation-duration: 0.001s !important;',
4
+ ' animation-delay: 0s !important;',
5
+ ' transition-duration: 0.001s !important;',
6
+ ' transition-delay: 0s !important;',
7
+ ' caret-color: transparent !important;',
8
+ '}',
9
+ ].join('\n');
10
+
11
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ /**
14
+ * Tracks in-flight network requests so the renderer can wait for the page to go
15
+ * quiet. Listeners must be attached before navigation to count the initial load.
16
+ */
17
+ export function trackNetwork(page) {
18
+ let inflight = 0;
19
+ const onStart = () => {
20
+ inflight += 1;
21
+ };
22
+ const onEnd = () => {
23
+ inflight = Math.max(0, inflight - 1);
24
+ };
25
+ page.on('request', onStart);
26
+ page.on('requestfinished', onEnd);
27
+ page.on('requestfailed', onEnd);
28
+
29
+ return {
30
+ async waitForQuiet({ quietPeriod, timeout }) {
31
+ const pollInterval = Math.max(10, Math.min(50, Math.floor(quietPeriod / 10)));
32
+ const startedAt = Date.now();
33
+ let quietSince = null;
34
+ while (true) {
35
+ if (inflight === 0) {
36
+ quietSince ??= Date.now();
37
+ if (Date.now() - quietSince >= quietPeriod) return;
38
+ } else {
39
+ quietSince = null;
40
+ }
41
+ if (Date.now() - startedAt > timeout) {
42
+ throw new Error(
43
+ `network did not settle within ${timeout}ms (${inflight} request(s) in flight)`,
44
+ );
45
+ }
46
+ await delay(pollInterval);
47
+ }
48
+ },
49
+ stop() {
50
+ page.off('request', onStart);
51
+ page.off('requestfinished', onEnd);
52
+ page.off('requestfailed', onEnd);
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Waits until the app declares itself ready. Apps opt in by defining the ready flag
59
+ * early and setting it to true when data has loaded; otherwise the network
60
+ * quiescence check below is the only signal.
61
+ */
62
+ export async function waitForReady(page, config) {
63
+ // Best-effort: one slow third-party asset should not cost a route its whole timeout, since
64
+ // the quiet-network check in settlePage is the real gate.
65
+ await page.waitForLoadState('load', { timeout: config.timeout }).catch(() => {});
66
+
67
+ if (config.waitFor) {
68
+ await page.waitForSelector(config.waitFor, { timeout: config.timeout });
69
+ }
70
+
71
+ if (config.readyFlag) {
72
+ const declared = await page.evaluate(
73
+ (flag) => typeof window[flag] !== 'undefined',
74
+ config.readyFlag,
75
+ );
76
+ if (declared) {
77
+ await page.waitForFunction((flag) => window[flag] === true, config.readyFlag, {
78
+ timeout: config.timeout,
79
+ polling: 100,
80
+ });
81
+ }
82
+ }
83
+
84
+ if (config.readySelector) {
85
+ await page.waitForSelector(config.readySelector, { timeout: config.timeout });
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Waits for fonts and two animation frames so late layout work lands before the
91
+ * DOM is serialised.
92
+ */
93
+ export async function waitForPaint(page) {
94
+ await page.evaluate(async () => {
95
+ await document.fonts.ready;
96
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Full settle sequence for one page: ready contract, quiet network, then paint.
102
+ */
103
+ export async function settlePage(page, config, tracker) {
104
+ await waitForReady(page, config);
105
+ await tracker.waitForQuiet(config);
106
+ await waitForPaint(page);
107
+ }
108
+
109
+ /**
110
+ * Neutralises CSS animations and transitions before capture, so route HTML does not
111
+ * freeze mid-animation. The injected style is tagged and stripped during
112
+ * normalisation.
113
+ */
114
+ export async function freezeAnimations(page) {
115
+ await page.evaluate((css) => {
116
+ const style = document.createElement('style');
117
+ style.id = 'snappy-freeze';
118
+ style.textContent = css;
119
+ document.head.appendChild(style);
120
+ }, FREEZE_CSS);
121
+ await page.evaluate(
122
+ () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Scrolls through the page in viewport steps to trigger IntersectionObserver-based
128
+ * content, then returns to the top.
129
+ */
130
+ export async function scrollThroughPage(page, { scrollStepDelay }) {
131
+ await page.evaluate(async (stepDelay) => {
132
+ const step = window.innerHeight;
133
+ const height = document.body.scrollHeight;
134
+ for (let offset = 0; offset < height; offset += step) {
135
+ window.scrollTo(0, offset);
136
+ await new Promise((resolve) => setTimeout(resolve, stepDelay));
137
+ }
138
+ window.scrollTo(0, 0);
139
+ await new Promise((resolve) => setTimeout(resolve, stepDelay));
140
+ }, scrollStepDelay);
141
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Inserts comment separators between adjacent text nodes before the DOM is
3
+ * serialised. HTML cannot express two neighbouring text nodes, so serialising a
4
+ * client-rendered DOM merges them, and hydration then finds one node where the
5
+ * client expects two — which React reports as a failed hydration. React's own server
6
+ * rendering emits the same separators, so this makes captured markup equivalent.
7
+ */
8
+ export async function separateTextNodes(page) {
9
+ await page.evaluate(() => {
10
+ // Elements whose children are raw text, where a comment would corrupt the content.
11
+ const RAW_TEXT = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'TITLE']);
12
+
13
+ // Walks the tree inserting a separator between each pair of adjacent text nodes.
14
+ const separate = (parent) => {
15
+ for (const child of Array.from(parent.childNodes)) {
16
+ if (child.nodeType === Node.ELEMENT_NODE) {
17
+ if (!RAW_TEXT.has(child.tagName)) separate(child);
18
+ continue;
19
+ }
20
+ if (child.nodeType !== Node.TEXT_NODE) continue;
21
+ const next = child.nextSibling;
22
+ if (next?.nodeType === Node.TEXT_NODE) {
23
+ parent.insertBefore(document.createComment(' '), next);
24
+ }
25
+ }
26
+ };
27
+
28
+ separate(document.documentElement);
29
+ });
30
+ }
@@ -0,0 +1,148 @@
1
+ import { blockThirdPartyRequests } from './blocking.js';
2
+ import { toServerUrl } from './routes.js';
3
+ import { mapLimit } from './scheduler.js';
4
+ import { settlePage, trackNetwork } from './settle.js';
5
+
6
+ const HYDRATION_PATTERNS = [
7
+ /hydrat/i,
8
+ /did not match/i,
9
+ /server-rendered HTML/i,
10
+ /react\.dev\/errors\/(418|419|421|422|423|424|425)/i,
11
+ /Minified React error #(418|419|421|422|423|424|425)\b/i,
12
+ ];
13
+
14
+ // Containers used by the common React setups, so the probe can find the prerendered
15
+ // subtree without configuration.
16
+ const CONTAINER_SELECTOR = '#root, #app, #__next';
17
+
18
+ /**
19
+ * Page-side probe installed before navigation. It remembers the first prerendered
20
+ * child of the app container, which is what lets the verifier tell hydration from a
21
+ * client re-render: hydration adopts those nodes, createRoot discards them.
22
+ */
23
+ function bootProbe() {
24
+ window.__snappyBoot = { prerenderedChild: null };
25
+
26
+ // Records the container's first child while the parser is still building the page.
27
+ const capture = () => {
28
+ if (window.__snappyBoot.prerenderedChild) return;
29
+ const container =
30
+ document.querySelector('#root, #app, #__next') ?? document.body?.firstElementChild;
31
+ if (!container) return;
32
+ window.__snappyBoot.prerenderedChild =
33
+ container.firstElementChild ?? container.firstChild ?? null;
34
+ };
35
+
36
+ const observer = new MutationObserver(capture);
37
+ observer.observe(document, { childList: true, subtree: true });
38
+ window.addEventListener('load', () => {
39
+ observer.disconnect();
40
+ capture();
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Classifies a console or page error as a React hydration failure. Exported so the
46
+ * pattern set is testable without a browser. Patterns stay narrow on purpose: a
47
+ * false positive fails a user's build, so unrelated 4xx React codes and generic
48
+ * "does not match" messages are deliberately not treated as hydration errors.
49
+ */
50
+ export function isHydrationError(message) {
51
+ return HYDRATION_PATTERNS.some((pattern) => pattern.test(message));
52
+ }
53
+
54
+ /**
55
+ * Classifies how a prerendered page booted. `hydrated` means the app adopted the
56
+ * markup, `re-rendered` means it discarded it, `undetected` means no React root was
57
+ * found on the container, and `unknown` means the prerendered child was never seen.
58
+ */
59
+ export function classifyBoot({ markupPreserved, frameworkMounted }) {
60
+ if (markupPreserved === null) return 'unknown';
61
+ if (!frameworkMounted) return 'undetected';
62
+ return markupPreserved ? 'hydrated' : 're-rendered';
63
+ }
64
+
65
+ /**
66
+ * Reloads every written route with the real client bundle and reports React
67
+ * hydration errors plus how each route booted. This is the pass that catches markup
68
+ * that only breaks once the app re-attaches, and apps that never attach to it at all.
69
+ */
70
+ export async function verifyRoutes({ browser, origin, routes, config }) {
71
+ const { results, errors } = await mapLimit(routes, config.concurrency, (route) =>
72
+ verifyRoute({ browser, origin, route, config }),
73
+ );
74
+ const verified = results.filter(Boolean);
75
+ const modes = { hydrated: 0, 're-rendered': 0, undetected: 0, unknown: 0 };
76
+ for (const entry of verified) modes[entry.mode] += 1;
77
+ return {
78
+ routes: verified,
79
+ modes,
80
+ ok: verified.every((entry) => entry.ok) && errors.length === 0,
81
+ errors: errors.map(({ item, error }) => ({ route: item, message: error.message })),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Verifies one route against the same request rules as the render pass, so what is
87
+ * verified matches what was rendered.
88
+ */
89
+ async function verifyRoute({ browser, origin, route, config }) {
90
+ const context = await browser.newContext({
91
+ viewport: config.viewport,
92
+ storageState: config.storageState ?? undefined,
93
+ userAgent: config.userAgent ?? undefined,
94
+ ignoreHTTPSErrors: config.ignoreHTTPSErrors,
95
+ });
96
+ const page = await context.newPage();
97
+ const hydrationErrors = [];
98
+ const consoleErrors = [];
99
+ const pageErrors = [];
100
+ // Hydration messages arrive through either channel, so both are classified the same way.
101
+ const collect = (message, target) => {
102
+ if (isHydrationError(message)) hydrationErrors.push(message);
103
+ else target.push(message);
104
+ };
105
+ page.on('console', (message) => {
106
+ if (message.type() === 'error') collect(message.text(), consoleErrors);
107
+ });
108
+ page.on('pageerror', (error) => collect(error.message, pageErrors));
109
+
110
+ const tracker = trackNetwork(page);
111
+ try {
112
+ if (config.blockThirdParty) {
113
+ await blockThirdPartyRequests(page, origin, config.allowedHosts);
114
+ }
115
+ await page.addInitScript(bootProbe);
116
+ await page.goto(toServerUrl(route, config.base, origin), {
117
+ waitUntil: 'domcontentloaded',
118
+ timeout: config.timeout,
119
+ });
120
+ await settlePage(page, config, tracker);
121
+
122
+ const boot = await page.evaluate((selector) => {
123
+ const probe = window.__snappyBoot ?? {};
124
+ const container =
125
+ document.querySelector(selector) ?? document.body?.firstElementChild ?? null;
126
+ const frameworkMounted = container
127
+ ? Object.keys(container).some(
128
+ (key) => key.startsWith('__reactContainer') || key === '_reactRootContainer',
129
+ )
130
+ : false;
131
+ const child = probe.prerenderedChild ?? null;
132
+ return { frameworkMounted, markupPreserved: child ? child.isConnected : null };
133
+ }, CONTAINER_SELECTOR);
134
+
135
+ return {
136
+ route,
137
+ hydrationErrors,
138
+ consoleErrors,
139
+ pageErrors,
140
+ ok: hydrationErrors.length === 0,
141
+ mode: classifyBoot(boot),
142
+ markupPreserved: boot.markupPreserved,
143
+ };
144
+ } finally {
145
+ tracker.stop();
146
+ await context.close();
147
+ }
148
+ }