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.
@@ -1,24 +1,13 @@
1
+ import { installClock, installClockScripts } from './clock.js';
1
2
  import { captureFormState } from './form-state.js';
2
3
  import { inlineStylesheets } from './inline-css.js';
3
- import { isPageEmpty } from './page-content.js';
4
+ import { localeContextOptions, localeStorageState, seedLocaleContext } from './locale-context.js';
5
+ import { readPageState } from './page-content.js';
4
6
  import { createResourceCollector } from './resources.js';
5
7
  import { normaliseRoute, toAppRoute, toServerUrl } from './routes.js';
6
8
  import { captureRuntimeStyles } from './runtime-styles.js';
7
9
  import { freezeAnimations, scrollThroughPage, settlePage, trackNetwork } from './settle.js';
8
- import { separateTextNodes } from './text-separators.js';
9
-
10
- const SCRIPT_ESCAPES = {
11
- '<': '\\u003C',
12
- '>': '\\u003E',
13
- '/': '\\u002F',
14
- '\u2028': '\\u2028',
15
- '\u2029': '\\u2029',
16
- };
17
-
18
- // Serialises a value for embedding in a script element without allowing tag breakout.
19
- function escapeForScript(value) {
20
- return JSON.stringify(value).replace(/[<>/\u2028\u2029]/g, (char) => SCRIPT_ESCAPES[char]);
21
- }
10
+ import { readRenderSnapshot } from './text-separators.js';
22
11
 
23
12
  /**
24
13
  * Renders one route in a fresh browser context and returns the serialised DOM plus
@@ -30,17 +19,20 @@ export async function renderRoute({
30
19
  browser,
31
20
  origin,
32
21
  route,
22
+ locale = null,
33
23
  config,
34
24
  screenshot = false,
35
25
  traceFile = null,
26
+ caches = null,
36
27
  }) {
37
28
  const context = await browser.newContext({
38
29
  viewport: config.viewport,
39
- storageState: config.storageState ?? undefined,
30
+ storageState: localeStorageState(config, locale),
40
31
  userAgent: config.userAgent ?? undefined,
41
32
  ignoreHTTPSErrors: config.ignoreHTTPSErrors,
42
33
  reducedMotion: config.freezeAnimations ? 'reduce' : 'no-preference',
43
34
  serviceWorkers: 'block',
35
+ ...localeContextOptions(config, locale),
44
36
  });
45
37
  let tracing = false;
46
38
  if (traceFile) {
@@ -48,6 +40,11 @@ export async function renderRoute({
48
40
  tracing = true;
49
41
  }
50
42
  const page = await context.newPage();
43
+ await installClock(page, config.fixedTime ?? null);
44
+ await installClockScripts(page, {
45
+ fixedTime: config.fixedTime ?? null,
46
+ randomSeed: config.randomSeed ?? null,
47
+ });
51
48
  const pageErrors = [];
52
49
  const consoleErrors = [];
53
50
  const failedRequests = [];
@@ -75,6 +72,15 @@ export async function renderRoute({
75
72
  const metadataDefaults = config.metadata
76
73
  ? { base: config.base, flatOutput: config.flatOutput, ...config.metadata }
77
74
  : null;
75
+ await seedLocaleContext({
76
+ page,
77
+ context,
78
+ origin,
79
+ config,
80
+ route,
81
+ locale,
82
+ phase: 'render',
83
+ });
78
84
  if (metadataDefaults) {
79
85
  await page.addInitScript((defaults) => {
80
86
  window.__SNAPPY_META__ = defaults;
@@ -127,20 +133,21 @@ export async function renderRoute({
127
133
  }
128
134
  }
129
135
  await settlePage(page, config, tracker);
130
- const empty = await isPageEmpty(page);
131
- const statusCode = await page.evaluate(() => {
132
- const marker = document.querySelector('meta[name="prerender-status-code"]');
133
- if (!marker) return null;
134
- const value = Number.parseInt(marker.getAttribute('content') ?? '', 10);
135
- return Number.isFinite(value) ? value : null;
136
- });
136
+ const { empty, statusCode } = await readPageState(page);
137
137
  const finalUrl = page.url();
138
138
 
139
139
  if (config.captureRuntimeStyles) await captureRuntimeStyles(page);
140
140
  if (config.captureFormState) await captureFormState(page);
141
141
  if (config.inlineCss === true || config.inlineCss === 'inline') {
142
- const inlined = await inlineStylesheets({ page, minifyCssOptions: config.minifyCss });
142
+ const inlined = await inlineStylesheets({
143
+ page,
144
+ minifyCssOptions: config.minifyCss,
145
+ cache: caches?.inlineCss ?? null,
146
+ });
143
147
  inlineCssSkipped = inlined.skipped;
148
+ if (caches?.inlineCss) {
149
+ for (const sheet of inlined.fetched ?? []) caches.inlineCss.set(sheet.href, sheet.css);
150
+ }
144
151
  }
145
152
  await collector.settled();
146
153
  const stateScript = await injectCapturedState(page, collector.json, {
@@ -150,7 +157,7 @@ export async function renderRoute({
150
157
  if (metadataDefaults) await injectMetadataDefaults(page, metadataDefaults);
151
158
  if (config.freezeAnimations) await freezeAnimations(page);
152
159
  if (config.scrollToBottom) await scrollThroughPage(page, config);
153
- const lcpUrl = await page.evaluate(() => window.__snappyLcp?.at(-1)?.url ?? null);
160
+ const { lcpUrl, hrefs } = await readRenderSnapshot(page);
154
161
  let lcpImage = null;
155
162
  if (lcpUrl) {
156
163
  try {
@@ -160,9 +167,7 @@ export async function renderRoute({
160
167
  // Ignore malformed performance entries.
161
168
  }
162
169
  }
163
- await separateTextNodes(page);
164
-
165
- const links = await collectLinks(page, config.base, origin);
170
+ const links = collectLinks(hrefs, config.base, origin);
166
171
 
167
172
  if (screenshot) {
168
173
  const buffer = await page.screenshot({
@@ -311,16 +316,21 @@ async function injectCapturedState(page, cache, { externalScripts = false, base
311
316
  * executable script, so a strict script-src applies to nothing here.
312
317
  */
313
318
  async function injectMetadataDefaults(page, defaults) {
314
- const payload = await page.evaluate((fallback) => {
319
+ await page.evaluate((fallback) => {
315
320
  document.querySelectorAll('script[data-snappy-meta]').forEach((script) => script.remove());
316
321
  const current = typeof window === 'undefined' ? null : window.__SNAPPY_META__;
317
322
  const recorded = typeof window === 'undefined' ? null : window.__snappyHeadOriginals;
318
323
  const merged = { ...(current ?? fallback) };
319
324
  if (recorded) merged.originals = recorded;
320
- return Object.keys(merged).length > 0 ? merged : fallback;
321
- }, defaults);
322
- const element = escapeForScript(payload);
323
- await page.evaluate((text) => {
325
+ const payload = Object.keys(merged).length > 0 ? merged : fallback;
326
+ const escapes = {
327
+ '<': '\\u003C',
328
+ '>': '\\u003E',
329
+ '/': '\\u002F',
330
+ '\u2028': '\\u2028',
331
+ '\u2029': '\\u2029',
332
+ };
333
+ const text = JSON.stringify(payload).replace(/[<>/\u2028\u2029]/g, (char) => escapes[char]);
324
334
  const script = document.createElement('script');
325
335
  script.setAttribute('type', 'application/json');
326
336
  script.setAttribute('data-snappy-meta', '');
@@ -328,7 +338,7 @@ async function injectMetadataDefaults(page, defaults) {
328
338
  const first = document.scripts[0];
329
339
  if (first?.parentNode) first.parentNode.insertBefore(script, first);
330
340
  else document.head.appendChild(script);
331
- }, element);
341
+ }, defaults);
332
342
  }
333
343
 
334
344
  /**
@@ -336,8 +346,7 @@ async function injectMetadataDefaults(page, defaults) {
336
346
  * app-relative routes for the crawler. Off-origin links are ignored so external
337
347
  * URLs can never be mistaken for routes and written into the output directory.
338
348
  */
339
- async function collectLinks(page, base, origin) {
340
- const hrefs = await page.$$eval('a[href]', (anchors) => anchors.map((anchor) => anchor.href));
349
+ function collectLinks(hrefs, base, origin) {
341
350
  const routes = [];
342
351
  for (const href of hrefs) {
343
352
  let parsed;
@@ -350,7 +359,7 @@ async function collectLinks(page, base, origin) {
350
359
  const route = toAppRoute(href, base);
351
360
  if (route) routes.push(route);
352
361
  }
353
- return routes;
362
+ return [...new Set(routes)];
354
363
  }
355
364
 
356
365
  // Parses an HTTP URL, returning null for data, blob and other non-network URLs.
@@ -34,7 +34,11 @@ export function buildReport(report, config = null) {
34
34
  version: 1,
35
35
  generatedAt: new Date().toISOString(),
36
36
  durationMs: report.durationMs,
37
+ timings: report.timings,
37
38
  ok: report.ok,
39
+ clock: report.clock ?? null,
40
+ locales: report.locales ?? null,
41
+ seoLocale: report.seoLocale ?? null,
38
42
  summary: {
39
43
  routes: report.routeDetails.length,
40
44
  files: report.files.filter((file) => file.status !== 'dry-run').length,
@@ -48,6 +52,9 @@ export function buildReport(report, config = null) {
48
52
  modes: report.verification.modes,
49
53
  frameworks: report.verification.frameworks,
50
54
  skipped: report.verification.skipped ?? 0,
55
+ policySkipped: report.verification.policySkipped ?? 0,
56
+ allowed: report.verification.allowed ?? 0,
57
+ allowedRerender: report.verification.allowedRerender ?? 0,
51
58
  errors: report.verification.errors,
52
59
  }
53
60
  : null,
@@ -85,9 +92,10 @@ function sanitiseValue(value, config) {
85
92
  let text = value;
86
93
  if (config?.storageState) {
87
94
  for (const secret of [config.storageState, path.resolve(config.storageState)]) {
88
- text = text.split(secret).join('[redacted]');
95
+ if (text.includes(secret)) text = text.split(secret).join('[redacted]');
89
96
  }
90
97
  }
98
+ if (!/https?:\/\//i.test(text)) return text;
91
99
  return text.replace(/https?:\/\/[^\s"'<>]+/gi, redactUrl);
92
100
  }
93
101
 
@@ -1,9 +1,11 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { isSafeRoute, normaliseRoute, toAppRoute } from './routes.js';
4
+ import { mapLimit } from './scheduler.js';
4
5
 
5
6
  const MAX_SOURCE_ROUTES = 100000;
6
7
  const FETCH_TIMEOUT = 10000;
8
+ const SITEMAP_FETCH_CONCURRENCY = 4;
7
9
  const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
8
10
 
9
11
  /**
@@ -80,9 +82,18 @@ async function readSitemapLevel(location, pageUrls, depth) {
80
82
  throw new Error(`${location} is not a sitemap: no urlset or sitemapindex element found`);
81
83
  }
82
84
  if (/<sitemapindex\b/i.test(text)) {
83
- for (const child of extractLocs(text)) {
84
- await readSitemapLevel(resolveChild(child, base), pageUrls, depth + 1);
85
- }
85
+ const children = extractLocs(text);
86
+ const { results, errors } = await mapLimit(
87
+ children,
88
+ SITEMAP_FETCH_CONCURRENCY,
89
+ async (child) => {
90
+ const routes = [];
91
+ await readSitemapLevel(resolveChild(child, base), routes, depth + 1);
92
+ return routes;
93
+ },
94
+ );
95
+ if (errors.length > 0) throw errors[0].error;
96
+ for (const routes of results) pageUrls.push(...routes);
86
97
  return;
87
98
  }
88
99
  pageUrls.push(...extractLocs(text));
package/src/core/run.js CHANGED
@@ -3,9 +3,11 @@ import path from 'node:path';
3
3
  import { writePreloadManifest, writeSitemap } from './artifacts.js';
4
4
  import { auditRun } from './audit/index.js';
5
5
  import { launchBrowser } from './browser.js';
6
+ import { resolveFixedTime } from './clock.js';
6
7
  import { resolveConfig } from './config.js';
7
8
  import { renderAllRoutes } from './crawl.js';
8
9
  import { prepareDestination } from './destination.js';
10
+ import { buildLocalePlan } from './locales.js';
9
11
  import { createLogger } from './log.js';
10
12
  import { routeToFile } from './output.js';
11
13
  import { pruneStaleFiles, readPruneManifest, writePruneManifest } from './prune.js';
@@ -29,12 +31,17 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
29
31
  for (const warning of config.warnings ?? []) log.warn(warning);
30
32
  const sourceDir = path.resolve(config.sourceDir);
31
33
  const startedAt = Date.now();
34
+ const fixedTime = resolveFixedTime(config, startedAt);
35
+ config.fixedTime = fixedTime;
36
+ const localePlan = buildLocalePlan(config);
32
37
 
33
38
  await assertSourceDir(sourceDir, config);
34
- const outputDir = await resolveOutputDir({ sourceDir, config, log });
39
+ const [outputDir, routeSources] = await Promise.all([
40
+ resolveOutputDir({ sourceDir, config, log }),
41
+ loadRouteSeeds(config),
42
+ ]);
35
43
  const previousManifest =
36
44
  config.pruneStaleRoutes && !config.dryRun ? await readPruneManifest(outputDir) : null;
37
- const routeSources = await loadRouteSeeds(config);
38
45
  for (const source of routeSources.sources) {
39
46
  const skipped = source.skipped > 0 ? `, ${source.skipped} skipped` : '';
40
47
  log.info(`Route source ${source.source}: ${source.routes} route(s)${skipped}`);
@@ -44,14 +51,21 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
44
51
  log.warn(`saveAs: '${config.saveAs}' writes images, so hydration verification is skipped`);
45
52
  }
46
53
 
47
- const server = config.serveCmd
48
- ? await startCommandServer({
49
- command: config.serveCmd,
50
- url: config.url,
51
- timeout: config.serveCmdTimeout,
52
- shutdownTimeout: config.shutdownTimeout,
53
- })
54
- : await startStaticServer({ dir: outputDir, base: config.base });
54
+ const browserPromise = launchBrowser(config, log);
55
+ let server;
56
+ try {
57
+ server = config.serveCmd
58
+ ? await startCommandServer({
59
+ command: config.serveCmd,
60
+ url: config.url,
61
+ timeout: config.serveCmdTimeout,
62
+ shutdownTimeout: config.shutdownTimeout,
63
+ })
64
+ : await startStaticServer({ dir: outputDir, base: config.base });
65
+ } catch (error) {
66
+ browserPromise.then(({ browser }) => browser.close()).catch(() => {});
67
+ throw error;
68
+ }
55
69
 
56
70
  const report = {
57
71
  routes: [],
@@ -67,6 +81,17 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
67
81
  notFoundRoutes: [],
68
82
  routeSources: routeSources.sources,
69
83
  routeDetails: [],
84
+ clock: fixedTime === null ? null : { fixedTime, timezone: config.timezone ?? null },
85
+ locales: localePlan
86
+ ? { mode: localePlan.mode, default: localePlan.default, active: localePlan.active ?? null }
87
+ : null,
88
+ seoLocale: config.seoLocale
89
+ ? {
90
+ code: config.seoLocale.code ?? config.locales?.active ?? null,
91
+ source: config.seoLocale.source,
92
+ inject: config.seoLocale.inject,
93
+ }
94
+ : null,
70
95
  cspSkipped: [],
71
96
  traces: [],
72
97
  verification: null,
@@ -77,16 +102,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
77
102
  truncated: null,
78
103
  ok: false,
79
104
  durationMs: 0,
105
+ timings: { render: 0, write: 0, artifacts: 0, verify: 0, audit: 0 },
80
106
  };
81
107
  const manifestEntries = [];
82
108
  const state = { warnedBuildOriginMetadata: false, warnedCriticalCssSkipped: false };
83
109
  let browser = null;
84
110
 
85
111
  try {
86
- const launched = await launchBrowser(config, log);
112
+ const launched = await browserPromise;
87
113
  browser = launched.browser;
88
114
  log.info(`Browser: ${launched.source}`);
115
+ const renderCaches = { inlineCss: new Map(), criticalCss: new Map() };
89
116
 
117
+ let phaseStartedAt = Date.now();
90
118
  const rendered = await renderAllRoutes({
91
119
  browser,
92
120
  server,
@@ -95,7 +123,19 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
95
123
  report,
96
124
  outputDir,
97
125
  sourceSeeds: routeSources.routes,
126
+ caches: renderCaches,
127
+ localePlan,
98
128
  });
129
+ report.timings.render = Date.now() - phaseStartedAt;
130
+ const { rendered: renderedRoutes, routeLocales } = rendered;
131
+ if (localePlan) {
132
+ const renderedByLocale = {};
133
+ for (const route of report.routes) {
134
+ const code = routeLocales.get(route)?.locale?.code ?? localePlan.default;
135
+ renderedByLocale[code] = (renderedByLocale[code] ?? 0) + 1;
136
+ }
137
+ report.locales.rendered = renderedByLocale;
138
+ }
99
139
  if (report.routes.length === 0) {
100
140
  report.errors.push({ route: null, message: 'no routes were rendered' });
101
141
  log.error('No routes were rendered; check include, exclude and sourceDir');
@@ -110,19 +150,32 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
110
150
  );
111
151
  }
112
152
 
153
+ phaseStartedAt = Date.now();
113
154
  const verifiable = await writeAllRoutes({
114
- rendered,
155
+ rendered: renderedRoutes,
156
+ routeLocales,
157
+ localePlan,
115
158
  outputDir,
116
159
  config,
117
160
  log,
118
161
  report,
119
162
  state,
120
163
  manifestEntries,
164
+ caches: renderCaches,
121
165
  });
166
+ report.timings.write = Date.now() - phaseStartedAt;
167
+ const localeScripts = report.files
168
+ .filter((file) => file.file?.startsWith('snappy-seo-locale-'))
169
+ .map((file) => file.file);
170
+ if (report.seoLocale) {
171
+ report.seoLocale.script = localeScripts[0] ?? null;
172
+ report.seoLocale.scripts = [...new Set(localeScripts)];
173
+ }
122
174
  logFileSummary(report, log);
123
175
  logNotFoundNotice(report, config, log);
124
176
  logDuplicateTitles(report, log);
125
177
 
178
+ phaseStartedAt = Date.now();
126
179
  if (config.sitemap) {
127
180
  try {
128
181
  await writeSitemap({ outputDir, config, log, report });
@@ -155,6 +208,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
155
208
  log.error(`preload manifest write failed: ${error.message}`);
156
209
  }
157
210
  }
211
+ report.timings.artifacts = Date.now() - phaseStartedAt;
158
212
 
159
213
  const canVerify = config.verify && !config.dryRun && config.saveAs === 'html';
160
214
  if (canVerify && config.serveCmd) {
@@ -164,13 +218,13 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
164
218
  }
165
219
  // With verifyChangedOnly, a route whose HTML file was left untouched is not reloaded: the HTML
166
220
  // and the bundle are the same as the last time it was verified.
221
+ const fileStatus = new Map(
222
+ report.files.map((file) => [`${file.route ?? ''}|${file.file ?? ''}`, file.status]),
223
+ );
167
224
  const toVerify = config.verifyChangedOnly
168
225
  ? verifiable.filter((route) => {
169
226
  const file = routeToFile(route, config);
170
- const entry = report.files.find(
171
- (candidate) => candidate.route === route && candidate.file === file,
172
- );
173
- return entry?.status !== 'unchanged';
227
+ return fileStatus.get(`${route}|${file}`) !== 'unchanged';
174
228
  })
175
229
  : verifiable;
176
230
  const skipped = verifiable.length - toVerify.length;
@@ -178,14 +232,18 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
178
232
  log.info(`Verification skipped for ${skipped} unchanged route(s), as verifyChangedOnly asks`);
179
233
  }
180
234
  if (canVerify && !config.serveCmd && toVerify.length > 0) {
235
+ phaseStartedAt = Date.now();
181
236
  report.verification = await verifyRoutes({
182
237
  browser,
183
238
  origin: server.origin,
184
239
  routes: toVerify.sort(),
185
240
  config,
186
241
  outputDir,
242
+ routeLocales,
187
243
  });
244
+ report.timings.verify = Date.now() - phaseStartedAt;
188
245
  report.verification.skipped = skipped;
246
+ report.verification.policySkipped = report.verification.skippedRoutes.length;
189
247
  logVerification(report.verification, config, log);
190
248
  mergeVerification(report);
191
249
  }
@@ -224,6 +282,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
224
282
 
225
283
  report.durationMs = Date.now() - startedAt;
226
284
  if (config.audit) {
285
+ const auditStartedAt = Date.now();
227
286
  try {
228
287
  report.audit = await auditRun({ report, config, outputDir });
229
288
  logAudit(report.audit, log);
@@ -231,8 +290,11 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
231
290
  report.errors.push({ route: null, message: `audit failed: ${error.message}` });
232
291
  log.error(`audit failed: ${error.message}`);
233
292
  }
293
+ report.timings.audit = Date.now() - auditStartedAt;
234
294
  }
235
295
  const reRendered = report.verification?.modes['re-rendered'] ?? 0;
296
+ const allowedRerender = report.verification?.allowedRerender ?? 0;
297
+ const notAllowedRerender = Math.max(0, reRendered - allowedRerender);
236
298
  const undetected = report.verification
237
299
  ? (report.verification.modes.undetected ?? 0) + (report.verification.modes.unknown ?? 0)
238
300
  : 0;
@@ -247,7 +309,7 @@ export async function prerender(userOptions = {}, { log: injectedLog } = {}) {
247
309
  !(config.failOnHydrationError && report.verification && !report.verification.ok) &&
248
310
  !(config.failOnVerificationError && report.verification?.errors.length > 0) &&
249
311
  !(config.failOnUndetected && undetected > 0) &&
250
- !(config.failOnRerender && reRendered > 0);
312
+ !(config.failOnRerender && notAllowedRerender > 0);
251
313
  try {
252
314
  report.reportFile = await writeReport({ report, config, log });
253
315
  } catch (error) {
@@ -300,12 +362,16 @@ async function resolveOutputDir({ sourceDir, config, log }) {
300
362
 
301
363
  // Adds the verification result to each route record for the JSON report.
302
364
  function mergeVerification(report) {
365
+ const details = new Map(report.routeDetails.map((record) => [record.route, record]));
303
366
  for (const entry of report.verification?.routes ?? []) {
304
- const detail = report.routeDetails.find((record) => record.route === entry.route);
367
+ const detail = details.get(entry.route);
305
368
  if (!detail) continue;
306
369
  detail.mode = entry.mode;
307
370
  detail.framework = entry.framework ?? null;
308
371
  detail.hydrationErrors = entry.hydrationErrors;
372
+ detail.ignoredHydrationErrors = entry.ignoredHydrationErrors ?? [];
373
+ detail.hydrationPolicy = entry.hydrationPolicy ?? null;
374
+ detail.hydrationAllowed = entry.allowed === true;
309
375
  detail.pageErrors = [...new Set([...(detail.pageErrors ?? []), ...entry.pageErrors])];
310
376
  detail.consoleErrors = [...new Set([...(detail.consoleErrors ?? []), ...entry.consoleErrors])];
311
377
  detail.verificationStatus = entry.status;
@@ -372,8 +438,10 @@ function logFileSummary(report, log) {
372
438
  // were reported but not enforced.
373
439
  function logVerification(verification, config, log) {
374
440
  const modes = verification.modes ?? {};
441
+ const allowed = verification.allowed ?? 0;
442
+ const policySkipped = verification.policySkipped ?? 0;
375
443
  log.info(
376
- `Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown`,
444
+ `Hydration modes: ${modes.hydrated ?? 0} hydrated, ${modes['re-rendered'] ?? 0} re-rendered, ${modes.undetected ?? 0} undetected, ${modes.unknown ?? 0} unknown${allowed > 0 ? `, ${allowed} allowed` : ''}${policySkipped > 0 ? `, ${policySkipped} skipped by policy` : ''}`,
377
445
  );
378
446
  const notDetected = (modes.undetected ?? 0) + (modes.unknown ?? 0);
379
447
  if (notDetected > 0) {
@@ -415,7 +483,9 @@ function logVerification(verification, config, log) {
415
483
  }
416
484
  }
417
485
 
418
- const reRendered = verification.routes.filter((entry) => entry.mode === 're-rendered');
486
+ const reRendered = verification.routes.filter(
487
+ (entry) => entry.mode === 're-rendered' && !entry.allowed,
488
+ );
419
489
  if (reRendered.length > 0) {
420
490
  const sample = reRendered
421
491
  .slice(0, 5)