snappy-prerender 2.6.0-rc.2 → 2.6.0

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 ADDED
@@ -0,0 +1,140 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
6
+ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Earlier history predates
7
+ the release tags; see the Git history for those changes.
8
+
9
+ ## [2.6.0] - 2026-10-27
10
+
11
+ ### Added
12
+
13
+ - `cleanDestination` empties the destination before copying the build output, so assets
14
+ removed from a build do not linger.
15
+ - `pruneStaleRoutes` removes pages recorded by the previous run's `.snappy-manifest.json`
16
+ when they are no longer produced, without touching assets or user files.
17
+ - New build gates: `failOnEmptyPage` (on by default), `failOnConsoleError`,
18
+ `failOnFailedRequest`, `failOnDuplicateContent`, `failOnUndetected`,
19
+ `failOnVerificationError` and the optional `failOnBuildOriginMetadata`.
20
+ - `browserDownloadVersion` pins the Chrome for Testing build id; a cached build is now
21
+ used without contacting the version feed, so offline rebuilds work.
22
+ - `stripBuildOriginMetadata` removes canonical and social tags that point at the
23
+ prerender server instead of shipping loopback URLs.
24
+ - Reports now expose `consoleErrors`, `failedRequests`, `emptyPages`, `duplicatePages`
25
+ and `pruned`; page files carry `noindex`, and verification entries carry `status` and
26
+ `empty`.
27
+ - CLI flags for the new gates and for `quietPeriod`, `scrollStepDelay`,
28
+ `serveCmdTimeout`, `shutdownTimeout`, `maxCachedBytes`, `viewport`, `readyFlag`,
29
+ `readySelector`, `headless`, `cleanDestination` and `metadata.base`.
30
+ - A Node 22/24 test workflow and a dedicated integration suite for the audit fixes.
31
+
32
+ ### Changed
33
+
34
+ - **Breaking:** the minimum supported Node version is now 22.12, matching
35
+ `@puppeteer/browsers`.
36
+ - **Breaking:** an explicit `browser` choice no longer falls back to another engine.
37
+ `chromium` uses a Playwright-managed Chromium and asks you to install it when missing;
38
+ `auto` is the only choice that walks machine, Playwright cache, cached Chrome for
39
+ Testing, then download.
40
+ - **Breaking:** `include` is a true output allowlist. A wildcard-only list still seeds the
41
+ root for crawling, but the seed itself is not written. Passing `include: ['/']` is
42
+ treated as the default and leaves crawling enabled.
43
+ - **Breaking:** canonical, Open Graph and sitemap URLs share one page-path policy:
44
+ directory output keeps its trailing slash, flat output stays extensionless, and an
45
+ explicit `trailingSlash` wins.
46
+ - **Breaking:** sitemap entries no longer carry `lastmod` (a build date claimed every page
47
+ changed), noindex pages are excluded, and sitemaps above 50,000 URLs are split behind a
48
+ sitemap index.
49
+ - **Breaking:** `metadata.siteUrl` must be an origin without a path; sub-path deployments
50
+ belong in `base`. A `serveCmd` URL with a path is rejected for the same reason.
51
+ - **Breaking:** `removeStyleTags` combined with `inlineCss: 'inline'` or `true` is
52
+ rejected instead of silently removing the CSS that was just inlined.
53
+ - Empty output now fails a route by default; 404 pages are emitted with a robots
54
+ `noindex` tag.
55
+ - Third-party blocking now covers WebSockets and accepts hostnames with ports, wildcard
56
+ suffixes and leading-dot suffixes. Service workers are blocked during rendering and
57
+ verification.
58
+ - Verification uses a real browser user agent and the same reduced-motion context as the
59
+ render pass; mode counts are always logged and can fail the build with
60
+ `failOnUndetected`.
61
+ - Inlined stylesheets rebase relative `url()` values against their source stylesheet;
62
+ sheets using `@import` stay linked to preserve the cascade.
63
+ - Preload manifests are derived from the final HTML, so inline CSS, tag surgery and failed
64
+ writes no longer leave stale hints. The not-found route is omitted.
65
+ - Screenshots use the same unchanged-file check as HTML and dry runs report the image file
66
+ instead of an HTML one.
67
+ - `init` reads Vite's resolved `outDir` and `base`, and Create React App's `homepage`, so
68
+ the wizard no longer guesses `dist` and `/` for custom projects. During a prerelease it
69
+ prints the `@next` install tag.
70
+ - Unknown config keys and options that have no effect in the current combination now
71
+ produce warnings instead of being ignored silently.
72
+ - Rendered output is split across smaller modules; `run.js` is back under the 600-line
73
+ limit.
74
+
75
+ ### Fixed
76
+
77
+ - `setHead` called an undefined helper and threw `ReferenceError`.
78
+ - HTTP 4xx/5xx navigations, empty pages and zero-route runs could be written and reported
79
+ as successful; they now fail with route-level errors.
80
+ - The static server served `index.html` for dead extensionless links and failed dotted
81
+ routes; HTML navigations now fall back deliberately and duplicate output is detected.
82
+ - Canonical and `og:url` doubled a non-root `base` path.
83
+ - `verifyChangedOnly` could skip a changed page when a shared state script was unchanged.
84
+ - `asyncScriptTags` marked the injected state script `async`, allowing it to run after the
85
+ application bundle.
86
+ - `removeAttributes` with a global or sticky RegExp skipped every other match.
87
+ - Output collisions that differ only by case are detected on case-insensitive
88
+ filesystems.
89
+ - A destination inside the source directory recursed until the path length limit.
90
+ - Inlined CSS lost relative font and image URLs.
91
+ - Build-server metadata shipped loopback canonical and social URLs.
92
+ - Preload manifests described resources the final page no longer loaded.
93
+ - Sitemap and canonical URLs disagreed on trailing slashes and flat output.
94
+ - Allowed-host matching ignored ports and wildcards, and WebSocket requests bypassed
95
+ blocking.
96
+ - Browser downloads needed the network even when a cached browser was available.
97
+ - The CLI advertised `--preconnect-third-party` without defining it, and several other
98
+ documented flags had no CLI equivalent.
99
+
100
+ ## [2.6.0-rc.3] - 2026-10-03
101
+
102
+ ### Added
103
+
104
+ - Computed config files: `snappy.config.js` may export an async function that returns
105
+ options, so routes can come from a CMS, sitemap or file listing.
106
+ - Sitemap generation and the `sitemap` option.
107
+ - Stable output: unchanged pages, state scripts and artifacts are left untouched.
108
+ - React 16.8 and 17 fixtures and integration coverage.
109
+
110
+ ### Changed
111
+
112
+ - Documentation links to the npm package and notes the release-candidate channel.
113
+
114
+ ## [2.6.0-rc.2] - 2026-09-26
115
+
116
+ ### Added
117
+
118
+ - `snappy-prerender init` setup wizard, with flags for non-interactive use.
119
+ - CSP-friendly output: `csp: 'off' | 'strict'`, `externalScripts` and configurable
120
+ `criticalCssPreload`.
121
+
122
+ ## [2.6.0-rc.1] - 2026-09-26
123
+
124
+ ### Added
125
+
126
+ - The `snappy-prerender/head` entry point: `Head`, `setHead`, `clearHead`, metadata
127
+ defaults, canonical, Open Graph and Twitter tags, and client-side reconciliation.
128
+ - Head-layer audit fixes for tag ownership, restoration and repeated runs.
129
+
130
+ ## [2.5.0] - 2026-09-25
131
+
132
+ ### Added
133
+
134
+ - react-snap feature parity for v2: `inlineCss`, `minifyHtml`, `minifyCss`,
135
+ `preloadImages`, `cacheAjaxRequests`, `snapSaveState`, `preloadManifest`,
136
+ `removeBlobs`, `removeStyleTags`, `removeScriptTags`, `asyncScriptTags`,
137
+ `destination`, `saveAs`, `browserArgs` and `ignoreHTTPSErrors`.
138
+ - Hydration verification that reports `hydrated`, `re-rendered`, `undetected` and
139
+ `unknown`, plus per-route React mismatch messages.
140
+ - Third-party requests allowed by default, with opt-in blocking and host allowlists.
package/README.md CHANGED
@@ -3,11 +3,14 @@
3
3
  **Prerender your single-page app without rewriting it.** Point it at your build output, and every route is rendered in a real browser and written to disk as static HTML — content, titles, meta tags, canonical links and Open Graph tags included. Search engines and social crawlers get the full page; your users get the same app they had before.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
+ [![npm downloads](https://img.shields.io/npm/dm/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
6
7
  [![node](https://img.shields.io/node/v/snappy-prerender.svg)](https://www.npmjs.com/package/snappy-prerender)
7
8
  [![license](https://img.shields.io/npm/l/snappy-prerender.svg)](https://github.com/sayakmukherjee080/snappy-prerender/blob/main/LICENSE)
8
9
 
10
+ Package page: [npmjs.com/package/snappy-prerender](https://www.npmjs.com/package/snappy-prerender). Install with `npm install --save-dev snappy-prerender`. Release notes are in [CHANGELOG.md](CHANGELOG.md).
11
+
9
12
  - **No SSR entry, no framework migration, no app changes.** If it builds to a `dist` directory, it can be prerendered.
10
- - **React 18 and React 19, Vite 6/7/8, or any static build.**
13
+ - **React 16.8, 17, 18 and 19, Vite 6/7/8, or any static build.**
11
14
  - **Inspired by [react-snap](https://github.com/stereobooster/react-snap)** and rebuilt from scratch for current toolchains: a browser-based prerenderer with a hydration report attached, not another SSR framework.
12
15
 
13
16
  ## Who this is for
@@ -60,6 +63,7 @@ npx snappy-prerender dist --include /,/pricing,/blog/* --exclude /blog/drafts/*
60
63
  npx snappy-prerender dist --block-third-party --allowed-hosts cms.example.com
61
64
  npx snappy-prerender dist --metadata-site-url https://example.com --metadata-title-template '%s | Example'
62
65
  npx snappy-prerender dist --inline-css critical --minify-html --preload-manifest
66
+ npx snappy-prerender dist --sitemap --verify-changed-only
63
67
  npx snappy-prerender dist --csp strict --inline-css critical
64
68
  ```
65
69
 
@@ -70,9 +74,10 @@ import { prerender } from 'snappy-prerender/core';
70
74
 
71
75
  const report = await prerender({ sourceDir: 'dist', include: ['/'] });
72
76
  if (!report.ok) {
73
- for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
77
+ for (const failure of report.verification?.routes.filter((entry) => !entry.ok) ?? []) {
74
78
  console.error(failure.route, failure.hydrationErrors);
75
79
  }
80
+ for (const failure of report.errors) console.error(failure.route, failure.message);
76
81
  }
77
82
  ```
78
83
 
@@ -150,7 +155,7 @@ Rules worth knowing:
150
155
  - When no Head wants a tag any more, a generated one is removed and one the app wrote is restored to the value it had before the head layer touched it. That original is recorded into the page during prerendering, so a route change brings the template's fallback metadata back.
151
156
  - The component renders nothing, so it cannot affect hydration of the page content.
152
157
  - The title template applies unless the title already ends with the template's fixed part, or is the site name itself, so a title composed by hand is never suffixed twice.
153
- - Absolute URLs are required for `og:url`, `og:image` and canonical. Without `metadata.siteUrl` they fall back to the browser origin, which during prerendering is the build server — the run warns when that happens. The `trailingSlash` convention applies to page URLs only; asset URLs are left alone.
158
+ - Absolute URLs are required for `og:url`, `og:image` and canonical. `metadata.siteUrl` must be an origin (no path); put a sub-path deployment in `base`. Without `siteUrl`, URLs that fall back to the build server are removed from the output by default (`stripBuildOriginMetadata: true`) and the run warns. The `trailingSlash` convention applies to page URLs only; asset URLs are left alone. Canonical, social and sitemap URLs share one page-path policy: directory output keeps its slash, flat output stays extensionless, and an explicit `trailingSlash` wins.
154
159
  - `metadata.base` overrides the base path used for absolute URLs and defaults to the `base` option.
155
160
  - On React 19, React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template tag alone, so a page can end up with two titles. Use React's native metadata or this component, not both; the run warns when the output carries more than one `<title>`.
156
161
  - Every metadata value has a CLI flag: `--metadata-site-url`, `--metadata-site-name`, `--metadata-title-template`, `--metadata-default-image`, `--metadata-trailing-slash`.
@@ -171,10 +176,16 @@ After writing the output, every route is loaded again with the real client bundl
171
176
  | `undetected` | No React root was found on the container, so the mode could not be judged. |
172
177
  | `unknown` | The prerendered node was never seen, for example a container outside the usual ids. |
173
178
 
179
+ A route whose verification navigation returns HTTP 4xx/5xx, or whose container renders no content, is reported as failed with its `status` or `empty` flag. `undetected` and `unknown` are reported as mode counts; set `failOnUndetected: true` to make any such route fail the build. A verification pass that cannot run is listed in `verification.errors`; set `failOnVerificationError: true` to fail on those separately from hydration errors.
180
+
174
181
  `re-rendered` matters because an app calling `createRoot` instead of `hydrateRoot` throws the prerendered DOM away on boot — and because `createRoot` cannot raise a hydration error, nothing else would ever tell you. It is reported per route as `verification.routes[].mode` and summarised in `verification.modes`, with a warning in the log. Set `failOnRerender: true` to make it fail the build as well.
175
182
 
176
183
  **Page errors.** Errors the browser logs while verifying are reported too: uncaught exceptions as warnings, console errors at debug level. Errors thrown during rendering itself are counted in the summary and reported per route, and `failOnPageError: true` turns them into a build failure; by default an app that throws still ships what it managed to render, because the static output is what crawlers read.
177
184
 
185
+ **React 16 and 17.** Hydration and boot modes are judged the same way, and their dev mismatch warnings ("Text content did not match", "Did not expect server HTML to contain") are recognised. Their production builds report nothing for a mismatch at all — no warning, no minified code — so on 16 and 17 the boot-mode report (`hydrated` versus `re-rendered`) is the signal that something drifted.
186
+
187
+ **Rebuilds.** On a large site most routes are unchanged between builds, and reloading them proves nothing new. `verifyChangedOnly: true` skips the verification pass for routes whose HTML file was left untouched, which roughly halves rebuild time; the report notes how many routes were skipped, and the first run after a change still verifies everything that moved.
188
+
178
189
  ## Ready contract
179
190
 
180
191
  Most apps work with no configuration because the network-quiet heuristic is enough. Apps that fetch data after load can opt into a stronger signal:
@@ -235,10 +246,20 @@ snappy({ preconnectThirdParty: true, preloadImages: true, preloadManifest: true
235
246
 
236
247
  - `preconnectThirdParty` (default `true`) records every third-party origin the page tried to reach — including ones blocked by `blockThirdParty` — and adds `<link rel="preconnect">` for each.
237
248
  - `preloadImages` adds `<link rel="preload" as="image">` for same-origin images the page loaded.
238
- - `preloadManifest` writes `preload-manifest.json` into the output directory: for each route, a `Link` header value listing the scripts and stylesheets it used, filtered by `ignoreForPreload` (default `['service-worker.js']`). Hints and route keys carry the base path, so serve these as Early Hints or `Link` response headers as they are.
249
+ - `preloadManifest` writes `preload-manifest.json` into the output directory: for each route, a `Link` header value listing the scripts and stylesheets the final HTML still references, filtered by `ignoreForPreload` (default `['service-worker.js']`). Inline CSS and tag surgery are already reflected, the not-found route is omitted, and hints and route keys carry the base path, so serve these as Early Hints or `Link` response headers as they are.
239
250
 
240
251
  react-snap called this `http2PushManifest`. Browsers removed HTTP/2 push (Chrome in v106), so the manifest is now a header list rather than a push list.
241
252
 
253
+ ### Sitemap
254
+
255
+ ```js
256
+ snappy({ sitemap: true, metadata: { siteUrl: 'https://example.com' } });
257
+ ```
258
+
259
+ `snappy` already knows every route it rendered, so it can write the sitemap for you. `sitemap.xml` is generated from the routes that were written — using the same page path policy as canonical URLs, with the base path applied, `noindex` pages and the not-found route left out — and split behind a sitemap index when it exceeds 50,000 URLs. Entries carry no `<lastmod>`, because a build date would claim every page changed on every build.
260
+
261
+ When the output directory already has a `robots.txt`, a `Sitemap:` line is appended to it if it does not point anywhere yet; an existing directive is left alone. Without a `robots.txt`, the run logs the exact line to add instead of creating a file you did not ask for.
262
+
242
263
  ### Tag surgery
243
264
 
244
265
  ```js
@@ -252,6 +273,24 @@ snappy({
252
273
 
253
274
  The persisted metadata element is exempt from `removeScriptTags`; everything else is removed as asked.
254
275
 
276
+ ### Removing volatile markup
277
+
278
+ Mark any element with `data-prerender-remove` and it is dropped from the output:
279
+
280
+ ```jsx
281
+ <div data-prerender-remove>{ads.render()}</div>
282
+ ```
283
+
284
+ Use it for regions that only make sense after the client takes over — ads, chat widgets, anything injected after mount. The rule of thumb: remove markup that React will *not* render on its first client pass. Removing something React does expect causes the very hydration mismatch it was meant to avoid, which is exactly what the verifier would then report.
285
+
286
+ Some libraries stamp values that change on every page load — antd's `rc-menu` writes `data-menu-id="rc-menu-uuid-…"` — which makes every rebuild rewrite every file and leaves `verifyChangedOnly` nothing to skip. List those attribute names (or RegExps) and they are dropped from the output:
287
+
288
+ ```js
289
+ snappy({ removeAttributes: ['data-menu-id', 'aria-controls'] });
290
+ ```
291
+
292
+ As with removed markup, only strip attributes React does not render as props; if you guess wrong, the hydration verifier tells you which route.
293
+
255
294
  ### Strict CSP
256
295
 
257
296
  One dial decides how the injected pieces are delivered. `csp: 'off'` (default) leaves them inline, which is cheapest to load. `csp: 'strict'` makes the whole output work under `script-src 'self'` with no `unsafe-inline`:
@@ -370,20 +409,38 @@ export default {
370
409
  };
371
410
  ```
372
411
 
412
+ The config can also be an async function that returns the options. That is how routes get discovered from a CMS, a sitemap or a directory listing, without a separate prebuild script:
413
+
414
+ ```js
415
+ // snappy.config.js
416
+ export default async function () {
417
+ const response = await fetch('https://cms.example.com/api/posts?fields=slug');
418
+ const posts = await response.json();
419
+ return {
420
+ sourceDir: 'dist',
421
+ include: ['/', '/blog', ...posts.map((post) => `/blog/${post.slug}`)],
422
+ metadata: { siteUrl: 'https://example.com', siteName: 'Example' },
423
+ };
424
+ }
425
+ ```
426
+
427
+ The function is awaited once per run, and whatever it returns must be a plain options object. If it throws, the CLI reports the reason and stops before rendering anything.
428
+
373
429
  ## Options
374
430
 
375
431
  | Option | Default | Description |
376
432
  | --- | --- | --- |
377
433
  | `sourceDir` | `dist` | Built output directory |
378
434
  | `destination` | `null` | Copy the output here and write results here instead of in place |
435
+ | `cleanDestination` | `false` | Empty the destination before copying, so removed assets do not linger |
379
436
  | `base` | `/` | Public base path |
380
- | `include` | `['/']` | Seed routes and allowlist. Wildcards (`/blog/*`) filter discovered routes, only concrete paths are seeded. Supplying your own list switches discovery off, so the listed routes are all that is rendered; leave it unset to render the root and crawl everything linked from it |
437
+ | `include` | `['/']` | Seed routes and output allowlist. Wildcards (`/blog/*`) filter discovered routes, only concrete paths are seeded, and a seed that does not match the list is used for crawling but not written. Passing `['/']` is treated as the default: crawl everything linked from the root |
381
438
  | `exclude` | `[]` | Routes to skip, strings or RegExp |
382
439
  | `crawl` | `true` | Follow same-origin links from seeds |
383
440
  | `maxDepth` | `null` | Crawl depth limit, `null` is unlimited, `0` renders seeds only |
384
441
  | `maxRoutes` | `null` | Stop after this many routes; hitting the cap marks the run incomplete |
385
442
  | `concurrency` | derived | Parallel pages, derived from CPU count, capped at 8 |
386
- | `timeout` | `30000` | Per-route timeout in ms |
443
+ | `timeout` | `30000` | Navigation, ready and network-settle timeout in ms; each wait gets this budget |
387
444
  | `quietPeriod` | `500` | Network must be idle this long before capture |
388
445
  | `readyFlag` | `__prerenderReady` | Window flag awaited when defined |
389
446
  | `readySelector` | `null` | Selector awaited when set |
@@ -394,6 +451,7 @@ export default {
394
451
  | `browser` | `auto` | `auto`, `chrome`, `msedge`, `chromium`, or a path to an executable |
395
452
  | `browserDownload` | `true` | Allow the Chrome for Testing fallback download |
396
453
  | `browserDownloadHash` | `null` | SHA-256 digest the fallback download must match, verified before use |
454
+ | `browserDownloadVersion` | `null` | Chrome for Testing build id to use for the cache and download instead of resolving `stable` |
397
455
  | `browserArgs` | `[]` | Extra browser launch arguments |
398
456
  | `headless` | `true` | Run the browser headless |
399
457
  | `ignoreHTTPSErrors` | `false` | Ignore TLS errors while rendering |
@@ -411,28 +469,40 @@ export default {
411
469
  | `minifyCss` | `false` | Minify CSS with clean-css, also applied to inlined CSS |
412
470
  | `preconnectThirdParty` | `true` | Add preconnect hints for every third-party origin the page contacts |
413
471
  | `preloadImages` | `false` | Add preload hints for same-origin images |
414
- | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints |
472
+ | `preloadManifest` | `false` | Write `preload-manifest.json` with Link header hints, derived from the final HTML |
415
473
  | `ignoreForPreload` | `['service-worker.js']` | File names excluded from the manifest |
474
+ | `sitemap` | `false` | Write `sitemap.xml` (or a sitemap index above 50,000 URLs) from the routes that do not declare noindex, and point an existing `robots.txt` at it; needs `metadata.siteUrl` |
416
475
  | `cacheAjaxRequests` | `false` | Expose captured JSON responses as `window.snapStore` |
417
- | `maxCachedBytes` | `5242880` | Largest JSON response cached per route, measured on the serialised body |
476
+ | `maxCachedBytes` | `5242880` | Largest JSON response cached per request, measured on the serialised body |
418
477
  | `removeBlobs` | `true` | Drop stylesheet links pointing at dead `blob:` URLs |
419
- | `removeStyleTags` | `false` | Strip every style tag from the output |
478
+ | `removeAttributes` | `[]` | Attribute names or RegExps to drop from the output, for libraries that stamp per-load ids |
479
+ | `stripBuildOriginMetadata` | `true` | Remove canonical and social tags whose URL points at the prerender server |
480
+ | `failOnBuildOriginMetadata` | `false` | Fail when metadata still points at the prerender server after stripping |
481
+ | `pruneStaleRoutes` | `false` | Delete pages listed by the previous run's manifest that this run no longer writes |
482
+ | `removeStyleTags` | `false` | Strip every style tag from the output; cannot be combined with `inlineCss` `'inline'` |
420
483
  | `removeScriptTags` | `false` | Strip every script tag from the output |
421
484
  | `asyncScriptTags` | `false` | Mark external scripts async |
422
485
  | `externalScripts` | `false` | Write injected state scripts as content-hashed same-origin files instead of inline; set by `csp: 'strict'` |
423
486
  | `flatOutput` | `false` | Write `about.html` instead of `about/index.html` |
424
- | `notFoundRoute` | `/404` | Route emitted as `404.html`; a notice is logged when it is never prerendered |
425
- | `saveAs` | `html` | `html`, `png` or `jpeg` |
426
- | `metadata` | `null` | Defaults for the head component: `siteUrl`, `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
487
+ | `notFoundRoute` | `/404` | Route emitted as `404.html` with a robots noindex tag; a notice is logged when it is never prerendered |
488
+ | `saveAs` | `html` | `html`, `png` or `jpeg`; identical screenshots are left untouched |
489
+ | `metadata` | `null` | Defaults for the head component: `siteUrl` (origin only), `siteName`, `titleTemplate`, `defaultImage`, `trailingSlash`, `base` |
427
490
  | `verify` | `true` | Run the hydration verification pass |
491
+ | `verifyChangedOnly` | `false` | Skip verification for routes whose HTML file did not change since the last run |
428
492
  | `failOnHydrationError` | `false` | Fail the run on hydration errors; off by default, where they are reported instead |
429
493
  | `failOnRerender` | `false` | Fail the run when a route re-renders instead of hydrating |
430
494
  | `failOnPageError` | `false` | Fail the run when a page throws while rendering; off by default, where page errors are reported |
495
+ | `failOnEmptyPage` | `true` | Fail when a route renders no app content |
496
+ | `failOnConsoleError` | `false` | Fail when the browser logs a console error while rendering |
497
+ | `failOnFailedRequest` | `false` | Fail when a same-origin request fails while rendering |
498
+ | `failOnDuplicateContent` | `false` | Fail when two routes produce byte-identical HTML |
499
+ | `failOnUndetected` | `false` | Fail when verification cannot classify any route as hydrated or re-rendered |
500
+ | `failOnVerificationError` | `false` | Fail when the verification pass cannot run on a route |
431
501
  | `failOnError` | `true` | Fail the run on route render errors |
432
502
  | `dryRun` | `false` | Render and report without writing |
433
503
  | `logLevel` | `info` | `silent`, `error`, `warn`, `info`, `debug` |
434
504
  | `serveCmd` | `null` | Command that starts the app server instead of static serving |
435
- | `url` | `null` | App URL, required with `serveCmd` |
505
+ | `url` | `null` | App origin, required with `serveCmd`; put a sub-path in `base` instead |
436
506
  | `serveCmdTimeout` | `30000` | How long to wait for `serveCmd` to answer |
437
507
  | `shutdownTimeout` | `5000` | Grace period before force-killing `serveCmd` |
438
508
 
@@ -445,13 +515,14 @@ import { prerender } from 'snappy-prerender/core';
445
515
 
446
516
  const report = await prerender({ sourceDir: 'dist', include: ['/'] });
447
517
  if (!report.ok) {
448
- for (const failure of report.verification.routes.filter((entry) => !entry.ok)) {
518
+ for (const failure of report.verification?.routes.filter((entry) => !entry.ok) ?? []) {
449
519
  console.error(failure.route, failure.hydrationErrors);
450
520
  }
521
+ for (const failure of report.errors) console.error(failure.route, failure.message);
451
522
  }
452
523
  ```
453
524
 
454
- The report exposes `routes`, `files` (with per-route `status`), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `duplicateTitles` (routes shipping more than one title), `verification` (per-route `mode` plus a `modes` summary), `preloadManifest`, `truncated`, and `ok`.
525
+ The report exposes `routes`, `files` (with per-route `status` and `noindex`), `errors`, `pageErrors` (uncaught browser errors seen while rendering), `consoleErrors`, `failedRequests`, `emptyPages`, `duplicatePages`, `duplicateTitles` (routes shipping more than one title), `verification` (nullable; per-route `mode`, `status` and `empty` plus a `modes` summary), `preloadManifest`, `sitemap`, `pruned`, `truncated`, and `ok`.
455
526
 
456
527
  `snappy-prerender` exports `DEFAULTS`, `prerender` and `resolveConfig`; the default export is the Vite plugin.
457
528
 
@@ -475,16 +546,22 @@ Dropped: `sourceMaps` (declared but never referenced in react-snap's code), `fix
475
546
  - Crawling is unbounded unless `maxRoutes` is set; hitting the cap marks the run as failed so incomplete output is never shipped silently. Which routes are cut is concurrency-dependent on a large site — narrow `include`/`exclude` instead when you need a deterministic set.
476
547
  - **React 19 native metadata.** React hoists `<title>` and `<meta>` rendered in JSX and leaves an existing template title in place, so using both systems produces two titles in the output. Use one of them; the run warns when a page ships more than one `<title>`.
477
548
  - If two routes would write the same file — such as `/` and `/index` with `flatOutput` — neither is written and the run fails with a collision error.
478
- - Screenshots are viewport-width full-page captures, so they reflect the configured `viewport`, not a device matrix.
479
- - Output files from routes that no longer exist are left in place; clean the output directory or use a fresh `destination` when route sets shrink.
549
+ - Screenshots are viewport-width full-page captures, so they reflect the configured `viewport`, not a device matrix. Identical screenshots are left untouched.
550
+ - **Output files from routes that no longer exist** are left in place unless `pruneStaleRoutes` is enabled, which deletes only files listed in the previous run's `.snappy-manifest.json`.
551
+ - **Dead extensionless links** are indistinguishable from SPA routes to the static server, so a missing page can render the shell or the app's catch-all 404. Duplicate output is reported; set `failOnDuplicateContent` to enforce it, and use an app-level 404 marker where possible.
552
+ - **Metadata pointing at the build server** is removed by default (`stripBuildOriginMetadata: true`) and reported. Set `metadata.siteUrl` so canonical and social URLs survive.
553
+ - **`inlineCss`** rebases relative font and image URLs against each stylesheet. A sheet that uses `@import` is left as a link because concatenating it would change the cascade.
554
+ - **`verifyChangedOnly` compares the generated HTML only.** If a bundle keeps the same filename while its contents change, that route's HTML is unchanged and its verification is skipped; content-hashed asset names (Vite and CRA defaults) make the HTML change as well.
555
+ - **Destination directories are merged unless `cleanDestination` is on.** That option empties the destination first; `pruneStaleRoutes` separately removes pages the run itself generated.
556
+ - **`removeStyleTags`** cannot be combined with `inlineCss: 'inline'` or `true`; the config is rejected instead of silently shipping pages without CSS.
480
557
 
481
558
  ## Requirements
482
559
 
483
- - Node.js >= 20.19
560
+ - Node.js >= 22.12
484
561
  - Chrome or Edge installed (detected automatically), or allow the one-time Chrome for Testing download fallback
485
562
  - `beasties` only if you use `inlineCss: 'critical'` (optional peer dependency)
486
563
 
487
- Browser resolution order: installed Chrome, installed Edge, a Playwright-managed browser, then a Chrome for Testing download from Google's version-pinned HTTPS bucket into `SNAPPY_BROWSER_CACHE_DIR` (defaults to the platform cache directory). Chrome for Testing publishes no checksum feed, so that archive is trusted on the strength of HTTPS and the pinned URL unless you set `browserDownloadHash`, which makes the download fail if the SHA-256 does not match. Set `browserDownload: false` to forbid downloads entirely.
564
+ Browser resolution order: installed Chrome, installed Edge, a Playwright-managed browser, a Chrome for Testing build already in the tool cache, then a Chrome for Testing download from Google's version-pinned HTTPS bucket into `SNAPPY_BROWSER_CACHE_DIR` (defaults to the platform cache directory). An explicit `browser` choice never falls back to another engine. Set `browserDownloadVersion` to pin the build id and `browserDownloadHash` to pin the archive digest; without a hash the download is trusted on the strength of HTTPS and the pinned URL. Set `browserDownload: false` to forbid downloads entirely.
488
565
 
489
566
  ## Contributing
490
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "snappy-prerender",
3
- "version": "2.6.0-rc.2",
3
+ "version": "2.6.0",
4
4
  "description": "Drop-in prerendering for existing single-page apps. Renders your built app in a real browser, writes static HTML per route, reports React hydration mismatches, and optimises the output with critical CSS, link hints and cached AJAX state.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@
26
26
  "playwright"
27
27
  ],
28
28
  "engines": {
29
- "node": ">=20.19.0"
29
+ "node": ">=22.12.0"
30
30
  },
31
31
  "main": "./src/index.js",
32
32
  "exports": {
@@ -36,14 +36,17 @@
36
36
  "./package.json": "./package.json"
37
37
  },
38
38
  "bin": {
39
- "snappy-prerender": "./src/cli.js"
39
+ "snappy-prerender": "src/cli.js"
40
40
  },
41
41
  "files": [
42
42
  "src",
43
+ "CHANGELOG.md",
43
44
  "LICENSE",
44
45
  "README.md"
45
46
  ],
46
- "sideEffects": false,
47
+ "sideEffects": [
48
+ "./src/head/manager.js"
49
+ ],
47
50
  "scripts": {
48
51
  "test": "node scripts/run-tests.mjs tests/unit",
49
52
  "test:integration": "node scripts/run-tests.mjs tests/integration",
@@ -65,14 +68,16 @@
65
68
  "beasties": "0.4.1",
66
69
  "react": "19.2.0",
67
70
  "react-dom": "19.2.0",
68
- "vite": "7.3.6"
71
+ "vite": "7.3.7"
69
72
  },
70
73
  "workspaces": [
74
+ "tests/fixtures/react16-app",
75
+ "tests/fixtures/react17-app",
71
76
  "tests/fixtures/react18-app"
72
77
  ],
73
78
  "peerDependencies": {
74
79
  "beasties": ">=0.4.1",
75
- "react": ">=18.0.0",
80
+ "react": ">=16.8.0",
76
81
  "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
77
82
  },
78
83
  "peerDependenciesMeta": {