deploy-recover
v0.1.0
Published
Recover tabs left open across a deploy: detect new versions, recover from missing chunks, and never loop reloading.
Maintainers
Readme
deploy-recover
Your users never see a broken page after a deploy.
You deploy. Someone who had your app open since this morning clicks a link, and the page goes blank. The console says:
TypeError: Failed to fetch dynamically imported module: https://app.com/assets/Settings-3f2a9c.jsTheir tab still runs the old version, and the old version's lazily loaded files were deleted by the deploy. Every app that uses code splitting (React.lazy, lazy routes, dynamic import()) hits this, on every deploy.
deploy-recover fixes it:
- Recovers missing chunks. When a lazy file fails to load, it checks whether a new version is live. If so, it reloads into it before the user sees an error. If not (the user is offline, or the server is down), it leaves the error alone.
- Never loops. A cache that keeps serving old files can't trap the tab in endless reloads. It gives up after 2 tries and tells you.
- Recovers blank pages too. If the browser restores old HTML whose main script is gone (the back button, a cached page), a tiny inline guard reloads it into the new version.
- Announces updates. It tells you when a new version is live, so you can show a "New version available" banner.
- Small. About 2.3 KB gzipped at runtime, plus 0.4 KB for the React helpers and a 0.7 KB inline guard. No dependencies.
Setup (Vite + React)
npm install deploy-recover1. Stamp each build with a version:
// vite.config.js
import react from "@vitejs/plugin-react";
import deployRecover from "deploy-recover/vite";
export default {
plugins: [react(), deployRecover()],
};This writes version.json into your build and adds a version tag to index.html. It does nothing in development.
The version is a hash of your build output, not your commit. Redeploying the same commit with a changed environment variable still changes your chunks (Vite inlines env values), so it counts as a new version. To choose your own, pass deployRecover({ version }) or set DEPLOY_RECOVER_VERSION. The commit SHA from your CI is recorded in version.json for reference.
deploy-recover is for single-page apps (a build with an
index.html). Server-rendered frameworks (Next.js, Remix, React Router framework mode) have noindex.htmlto stamp; there the runtime finds no version and stays inactive.
2. Start the guard once, when your app starts:
// main.jsx
import { createDeployRecovery } from "deploy-recover";
createDeployRecovery();3. Use lazy from deploy-recover instead of React's:
import { lazy } from "deploy-recover/react";
const Settings = lazy(() => import("./pages/Settings"));It's a drop-in replacement for React.lazy. When a deploy removed the chunk, the page reloads into the new version and your error boundary never flashes. Any other failure reaches your error boundary as usual.
That's all you need. Without step 3, deploy-recover still catches failures through Vite's vite:preloadError event and failed <script>/<link> loads. The difference is that your error boundary may briefly show before the reload.
Show an "update available" banner (optional)
import { useDeployRecovery } from "deploy-recover/react";
function UpdateBanner() {
const { status, reload } = useDeployRecovery();
if (status === "update-available") {
return <div role="status">A new version is available. <button onClick={reload}>Refresh</button></div>;
}
if (status === "recovery-failed") {
return <div role="alert">This page is out of date. Please refresh your browser.</div>;
}
return null;
}Options
createDeployRecovery({
pollInterval: 5 * 60 * 1000, // check for new versions every 5 min (0 = off)
checkOnFocus: true, // also check when the tab becomes visible / comes back online
reloadWhenHidden: false, // auto-reload into updates while the tab is in the background
isSafeToReload: () => !formIsDirty, // never auto-reload over unsaved work
maxReloads: 2, // automatic attempts per new version before giving up
reloadTimeout: 10000, // if a reload is cancelled ("Leave site?" → Stay), resume after this
onUpdateAvailable: (state) => {},
onRecoveryFailed: (state) => {}, // show a "please refresh" message
onReload: (reason, state) => {}, // "chunk-error" | "update", e.g. for analytics
});| Status | Meaning |
|---|---|
| current | Running the latest version, as far as we know |
| update-available | A new version is live; the tab still works |
| reloading | Reloading into the new version |
| recovery-failed | Reloading didn't reach the new version (see Caching) |
When does it reload by itself?
- After a chunk fails and a new version is live. The page is already broken, so reloading is the fix.
- When an update is available but nothing is broken: only if you turn on
reloadWhenHidden, and then only while the tab is in the background andisSafeToReload()returns true. Otherwise you getonUpdateAvailable, and the choice is yours.
If the user cancels the reload (your app shows a "Leave site?" prompt for unsaved changes and they choose Stay), deploy-recover goes back to update-available after reloadTimeout. The failed lazy import then reaches your error boundary instead of spinning forever.
Plugin options: deployRecover({ version, fileName, bootGuard }).
- Set
bootGuard: falseif your Content-Security-Policy blocks inline scripts. - If your app already ships a
public/version.json, the build stops with a clear message. Pick another name withfileName: "deploy-recover.json". - Assets on a CDN (an absolute
baselikehttps://cdn.example.com/) are supported.version.jsonis then fetched from the CDN, so the CDN must send CORS headers for it.
Other build tools
For any static build (webpack, Create React App, Rollup, Parcel, a static-exported site), stamp the output folder after building:
"scripts": {
"build": "webpack --mode production && deploy-recover stamp dist"
}deploy-recover stamp <outDir> [--version <id>] [--base shop/] [--file version.json] [--no-boot-guard]Then use createDeployRecovery() and recoverImport() in your app:
import { createDeployRecovery, recoverImport } from "deploy-recover";
createDeployRecovery();
const loadSettings = recoverImport(() => import("./settings.js"));The version defaults to a hash of the files in <outDir>. The runtime recognises webpack's ChunkLoadError and the chunk-error messages of Chrome, Firefox and Safari. Only the Vite + Chrome path is covered by the end-to-end suite, so treat other bundlers and browsers as supported but less proven. One known gap: the inline boot guard reacts to failed entry files. A classic (non-module) entry script that receives HTML fails with a syntax error instead of a load error, so that case isn't covered yet.
Git Bash on Windows rewrites arguments that start with
/, so write--base shop/rather than--base /shop/. PowerShell and CMD are unaffected.
Caching
deploy-recover relies on two files never being cached:
| File | Header |
|---|---|
| index.html (and other HTML) | Cache-Control: no-cache |
| version.json | Cache-Control: no-store |
Hashed assets (/assets/*) can and should stay cached forever. Most hosts already treat HTML this way. If yours doesn't:
Netlify / Cloudflare Pages (_headers file in your output folder):
/index.html
Cache-Control: no-cache
/version.json
Cache-Control: no-storeVercel (vercel.json):
{ "headers": [
{ "source": "/index.html", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
{ "source": "/version.json", "headers": [{ "key": "Cache-Control", "value": "no-store" }] }
] }nginx:
location = /index.html { add_header Cache-Control "no-cache"; }
location = /version.json { add_header Cache-Control "no-store"; }Service workers / PWAs (e.g. vite-plugin-pwa): a service worker serves cached HTML, so reloading doesn't reach the new version by itself. Use your service worker's own update flow (for vite-plugin-pwa, registerSW({ onNeedRefresh })). If you run deploy-recover alongside it, deploy-recover detects the stuck state and reports recovery-failed rather than reloading in a loop.
How it was tested
Besides unit tests, an end-to-end suite builds a real Vite + React + React Router app as v1 and v2. It serves them from a test server and drives a real Chrome tab that stays open while v2 is deployed. Every scenario checks how many times the page actually loaded:
| Scenario | Result |
|---|---|
| Missing chunk returns 404 | Reloads once into v2 |
| Host serves index.html (200) for missing files (Netlify/Vercel/nginx style) | Reloads once into v2 |
| A cache keeps serving the old app | Stops after 2 reloads, reports recovery-failed |
| Old HTML whose entry script was deleted (back button) | Inline guard reloads into v2 |
| …on a host that serves index.html (200) for missing files | Inline guard reloads into v2 |
| …and served forever | Inline guard stops after 2 reloads |
| Chunk missing but no new deploy | No reload; your error boundary shows the error |
| New version live, nothing failing | Reports update-available, no reload |
| App served from a subpath (/app/) | Finds /app/version.json and recovers |
| Build stamped by the CLI instead of the Vite plugin | Recovers |
| Same commit redeployed with a changed env value (default versioning) | Recovers |
| App already has public/version.json | Build fails with a clear message |
Run it with npm run test:e2e. It drives your installed Chrome; the suite runs in Chrome only.
License
MIT
