diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..004d07d
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,42 @@
+name: Pages
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm build
+ - run: pnpm demo:build
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: example/dist-demo
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index e418019..b6aa9a6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ node_modules
# builds
dist
+dist-demo
build
# misc
diff --git a/.prettierignore b/.prettierignore
index 7d4e373..8cc0ea8 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,5 @@
dist
+dist-demo
coverage
pnpm-lock.yaml
playwright-report
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b609bef
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,29 @@
+# Changelog
+
+## 1.0.0
+
+### Breaking changes
+
+- React 18 or newer is required.
+- The package ships ESM and CommonJS builds through `exports`; deep imports into `dist/` no longer resolve.
+- The stored version is saved only when the cache is cleared, so `isLatestVersion` stays `false` until the reload.
+- `lazyRetry(fn, 0)` makes one attempt instead of retrying forever.
+- `debug` receives `error: unknown` and always receives `state`.
+
+See [Upgrading from 0.x](README.md#upgrading-from-0x).
+
+### Features
+
+- `onUpdate` (`'prompt' | 'reload' | 'flag'`) decides what a check that finds a newer build does. `auto` still works and is deprecated.
+- `prompt` asks the user to reload with your own component, which receives `UpdatePromptProps`; `promptOnChunkError` asks before reloading for a chunk error too.
+- `reloadWhenHidden` reloads into a new build when the tab goes to the background, and checks once on the way.
+- `canReload` holds back reloads the user did not start.
+- `blocking={false}` renders the app before the first check.
+- A tab that gets focus back after a whole `duration` checks at once.
+- Next.js support: the bundle is marked `'use client'`, renders on the server and hydrates without a mismatch; Turbopack chunk errors are recognised.
+- `defaultErrorCheckers` and `isCacheError` are exported.
+
+### Fixes
+
+- `lazyRetry` no longer retries forever on `0`, and keeps its result type.
+- Chunk errors from Vite, mini-css-extract-plugin, native dynamic `import()` in every major browser, and chunks answered with HTML are recognised; 0.2 knew only webpack and one SyntaxError form.
diff --git a/README.md b/README.md
index 8953e64..9914246 100644
--- a/README.md
+++ b/README.md
@@ -1,129 +1,275 @@
# react-clear-browser-cache
-> Library for clearing browser cache after react app updates
-
-[](https://www.npmjs.com/package/react-clear-browser-cache) [](https://standardjs.com)
+> Keep open tabs on your latest React build: notice a deploy, clear Cache Storage and reload, or ask the user first.
+
+[](https://www.npmjs.com/package/react-clear-browser-cache)
+[](https://www.npmjs.com/package/react-clear-browser-cache)
+[](https://github.com/devmaxxx/react-clear-browser-cache/actions/workflows/ci.yml)
+[](https://bundlephobia.com/package/react-clear-browser-cache)
+
+[](#license)
+
+Users leave tabs open for days. After you deploy, those tabs still run the old build, and the moment they open a lazy route its chunk is gone from the server: `ChunkLoadError`, a blank screen, a support ticket. This library notices the new build and gets the tab onto it.
+
+**[▶ Try the live demo](https://devmaxxx.github.io/react-clear-browser-cache/)** — deploy a new version in one click and watch an open tab notice it, prompt, clear Cache Storage and reload.
+
+
+
+
+
+## Features
+
+- **Detects new builds** by polling a tiny `meta.json` that a CLI writes at build time.
+- **Recovers from stale-chunk errors** — webpack and Vite chunk failures, native `import()` errors in Chrome, Firefox and Safari, and HTML served in place of a script — and passes every other error on to your own error boundary.
+- **Asks or acts:** reload on its own, quietly while the tab is in the background, or show an accessible update prompt (built in, or your own component). A `canReload` guard protects unsaved work.
+- **Checks when users return:** a tab that was in the background for a while checks for a new build as soon as it gets focus.
+- **Clears Cache Storage** before reloading, so a service worker cannot serve the old build again.
+- **`lazyRetry`** for flaky networks: retries a dynamic import before giving up.
+- **Small and typed:** React 18 and 19, ESM and CommonJS, TypeScript declarations, no runtime dependencies.
+
+## Contents
+
+- [Install](#install)
+- [Quick start](#quick-start)
+- [Next.js](#nextjs)
+- [How it works](#how-it-works)
+- [Update prompt](#update-prompt)
+- [API](#api)
+- [Recipes](#recipes)
+- [Troubleshooting](#troubleshooting)
+- [Upgrading from 0.x](#upgrading-from-0x)
+- [Contributing](#contributing)
+- [License](#license)
## Install
```bash
-npm install --save react-clear-browser-cache
-
-or
-
+npm install react-clear-browser-cache
+# or
+pnpm add react-clear-browser-cache
+# or
yarn add react-clear-browser-cache
```
Requires React 18 or newer. The `generate-build-meta` CLI needs Node.js 18.3 or newer.
-## Add script in package.json
+## Quick start
-Generate a `meta.json` file with a fresh build version before every build.
+**1. Write a build version on every build.** Add the CLI as a `prebuild` script:
```json
{
- "prebuild": "generate-build-meta"
+ "scripts": {
+ "prebuild": "generate-build-meta"
+ }
}
```
-The file is written to `./public/meta.json` by default; pass
-`--destination ` to write it elsewhere (for example
-`generate-build-meta --destination static/meta.json`). The command exits with
-code 1 when it cannot write the file.
-
-## Usage
-
-### Using `Boundary`:
+It writes `{ "version": "" }` to `./public/meta.json`. Use `--destination ` for another location, e.g. `generate-build-meta --destination static/meta.json`. It exits with code 1 when it cannot write the file.
-`ClearBrowserCacheBoundary` has context provider for child elements (`useClearBrowserCache`), add him after your `ErrorBoundary`, because `ClearBrowserCacheBoundary` only handles certain errors like `ChunkLoadError`.
+**2. Wrap your app** inside your own error boundary:
```tsx
import { createRoot } from 'react-dom/client';
-
import { ClearBrowserCacheBoundary } from 'react-clear-browser-cache';
import App from './App';
import ErrorBoundary from './ErrorBoundary';
+import { UpdateDialog } from './UpdateDialog'; // your prompt, see below
createRoot(document.getElementById('root')!).render(
-
+
);
```
-### How it decides to reload
-
-- On mount the boundary shows `fallback` and fetches the meta file. On the first
- visit it records that version and renders the app. If a newer build is found
- later, it clears Cache Storage and reloads the page — on mount always, while
- polling only when `auto` is set; otherwise it sets `isLatestVersion` to
- `false` and leaves the reload to you (`clearCacheAndReload`).
-- Polling runs every `duration` ms while the window has focus.
-- When a child throws an error that looks like a stale build (a missing chunk,
- or a chunk URL answered with `index.html`), the boundary checks the version:
- if a new build is out it reloads, otherwise the error goes on to your
- `ErrorBoundary`. Recognised out of the box: webpack `ChunkLoadError` and CSS
- chunk errors, Vite CSS preload errors, native `import()` failures in
- Chrome, Firefox and Safari, and `SyntaxError: Unexpected token '<'`.
-- A meta file that is missing, has no `version`, or is answered with HTML by an
- SPA fallback is reported to `debug` and otherwise ignored; the app still
- renders.
-
-## Props
-
-| Name | Type | Default | More info |
-| :------------ | :------- | :----------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| duration | number | | You can set the duration when to fetch for new updates. |
-| auto | boolean | false | Set to true to auto-reload the page whenever an update is available. |
-| fallback | any | null | You can set fallback data when fetching new version - preloader etc. |
-| storageKey | string | APP_VERSION | Storage key for saving app version. |
-| storage | object | localStorage | Any `{ get(key), set(key, value) }` object, e.g. [store](https://www.npmjs.com/package/store). |
-| filename | string | meta.json | Meta file to fetch. A bare name is fetched from the site root; a path starting with `/`, `./` or `../`, or a full URL, is used as is (e.g. `` `${import.meta.env.BASE_URL}meta.json` `` for an app under a sub-path). |
-| errorCheckers | array | [] | Extra `(error) => boolean` checks for errors caused by a stale build, added to the defaults. `createErrorChecker(name, regexp)` builds one. |
-| debug | function | | You can debug state and errors. |
-
-### Using `hook`:
+**3. Deploy.** Tabs with the old build now see a prompt within a minute, or as soon as a lazy chunk fails to load.
+
+Prefer no prompt at all? `onUpdate='reload'` reloads as soon as a poll finds the new build. Gentler: set `reloadWhenHidden`, and the tab reloads into the new build the moment the user switches away from it. Add `canReload` to hold off while a form has unsaved changes.
```tsx
-import React from 'react';
+ !formIsDirty()}
+>
+```
-import { useClearBrowserCache } from 'react-clear-browser-cache';
+## Next.js
-function App() {
- const contextValue = useClearBrowserCache();
+The package is marked `'use client'`, and it reads `localStorage` only after mount, so it renders on the server and hydrates without a mismatch. Works with the App Router and the Pages Router, webpack or Turbopack; chunk errors from both are recognised.
- return null;
+`generate-build-meta` writes `public/meta.json`, which Next.js serves from the site root, so the `prebuild` script from the quick start works as is. Wrap the app in a client component, because a server component cannot pass functions such as `canReload` to it:
+
+```tsx
+// app/providers.tsx
+'use client';
+
+import { ClearBrowserCacheBoundary } from 'react-clear-browser-cache';
+
+import { UpdateDialog } from './UpdateDialog';
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
}
```
-### Using `render props`:
+```tsx
+// app/layout.tsx
+import { Providers } from './providers';
+
+export default function RootLayout({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+```
+
+- `blocking={false}` keeps the server-rendered page visible while the first check runs. With the default `blocking`, the server sends `fallback` and the page appears only after the check.
+- With a `basePath`, pass it in `filename`: `filename='/docs/meta.json'`.
+- Errors in a route segment are caught by the nearest `error.tsx` first, so a chunk error inside a page reaches the boundary only when that segment has no `error.tsx`. In one that does, call `clearCacheAndReload` from it for chunk errors.
+
+## How it works
+
+```mermaid
+flowchart LR
+ A[Mount] --> B{fetch meta.json}
+ B -- first visit --> C[store version, render app]
+ B -- same version --> C
+ B -- newer version --> D[clear Cache Storage, reload]
+ C --> E[poll every duration ms while focused]
+ E -- newer version --> F{onUpdate}
+ F -- reload --> D
+ F -- prompt --> G[show prompt]
+ F -- flag --> L[isLatestVersion = false]
+ C --> H[child throws a stale-chunk error]
+ H --> I{newer version?}
+ I -- yes --> J[reload, or prompt with promptOnChunkError]
+ I -- no --> K[rethrow to your ErrorBoundary]
+```
+
+- On mount the boundary shows `fallback` and fetches the meta file; with `blocking={false}` it renders the app at once and checks in the background. A newer build found on mount always reloads straight away.
+- Polling runs every `duration` ms, only while the window has focus. A tab that gets focus back after a whole `duration` checks at once. When a check finds a newer build, `onUpdate` decides: `prompt` shows `prompt`, `reload` reloads at once, `flag` only sets `isLatestVersion` to `false` and leaves the reload to you (`clearCacheAndReload`). It defaults to `prompt` when a `prompt` is given and to `flag` otherwise.
+- With `reloadWhenHidden`, a tab that knows about a newer build reloads as soon as it goes to the background. It also covers a build found after the tab was hidden, and it reloads even when the user dismissed the prompt for that build. With `duration` set, going to the background also checks once, so a deploy since the last poll is caught too.
+- `canReload` is asked before every reload the user did not start: `onUpdate='reload'`, `reloadWhenHidden`, and the check on mount with `blocking={false}`. When it says no, the user is shown `prompt` instead. A reload the user clicks is never blocked.
+- When a child throws an error that looks like a stale build, the boundary checks the version: if a new build is out it reloads (or prompts, with `promptOnChunkError`); otherwise the error goes on to your `ErrorBoundary`.
+- The meta file is fetched with `cache: 'no-store'`. A file that is missing, has no `version`, or is answered with HTML by an SPA fallback is reported to `debug` and otherwise ignored; the app still renders.
+- If storage cannot be read (blocked cookies, private mode), the boundary disables itself and just renders the app.
+
+## Update prompt
+
+The library ships no prompt UI, so the prompt matches your design system. Pass a component; it receives [`UpdatePromptProps`](#updatepromptprops). Declare it at module level: a new function on every render remounts the prompt.
```tsx
-import React from 'react';
+import type { UpdatePromptProps } from 'react-clear-browser-cache';
+
+export function UpdateDialog({ reason, reload, dismiss }: UpdatePromptProps) {
+ return (
+
+ {reason === 'error'
+ ? 'Part of the app could not load. Reload to continue.'
+ : 'A new version is available.'}
+ Later
+ Reload
+
+ );
+}
+```
+
+A complete one to copy is the demo's [`UpdateDialog.tsx`](example/src/UpdateDialog.tsx) with its [CSS](example/src/UpdateDialog.css): a native modal `` that focuses **Reload**, dismisses on Escape or **Later**, gives focus back when it closes, and locks its buttons while the page reloads. A dismissed prompt stays hidden until a newer build appears.
-import { ClearBrowserCache } from 'react-clear-browser-cache';
+A plain element works too (`prompt={ }`); it reads `clearCacheAndReload` and `dismissUpdate` from `useClearBrowserCache()`.
-function App() {
- return {(contextValue) => null} ;
+## API
+
+### ``
+
+Error boundary and context provider. Place it inside your own error boundary: it only handles stale-build errors.
+
+| Prop | Type | Default | Description |
+| :------------------- | :------------------------------- | :-------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
+| `duration` | `number` | — | Poll interval in ms. Without it the version is checked on mount and on stale-chunk errors only. |
+| `onUpdate` | `'prompt' \| 'reload' \| 'flag'` | see description | What a check that finds a newer build does. Defaults to `prompt` when `prompt` is given, `flag` otherwise. |
+| `auto` | `boolean` | `false` | Deprecated: same as `onUpdate='reload'`. |
+| `fallback` | `ReactNode` | `null` | Rendered while the first version check runs, e.g. a preloader. |
+| `prompt` | element or component | — | Asks the user to reload: your component, or an element. See [Update prompt](#update-prompt). |
+| `promptOnChunkError` | `boolean` | `false` | On a stale-build error, show `prompt` over `fallback` instead of reloading at once. |
+| `reloadWhenHidden` | `boolean` | `false` | Reload into a newer build as soon as the tab goes to the background. |
+| `canReload` | `() => boolean` | — | Asked before reloads the user did not start. Return `false` to keep the page. |
+| `blocking` | `boolean` | `true` | Show `fallback` until the first check answers. `false` renders the app at once. |
+| `filename` | `string` | `'meta.json'` | Meta file to fetch. A bare name is fetched from the site root; a path starting with `/`, `./` or `../`, or a full URL, is used as is. |
+| `storageKey` | `string` | `'APP_VERSION'` | Key the current version is stored under. |
+| `storage` | `{ get(key), set(key, value) }` | `localStorage` | Where the version is kept, e.g. [store](https://www.npmjs.com/package/store). |
+| `errorCheckers` | `((error) => boolean)[]` | `[]` | Extra checks for stale-build errors, added to the defaults. See [`createErrorChecker`](#createerrorcheckername-pattern). |
+| `debug` | `(params) => void` | — | Receives state changes and errors, for logging. |
+
+### `useClearBrowserCache()` and ``
+
+Read the boundary's state from any child, by hook or by render prop:
+
+```tsx
+import {
+ ClearBrowserCache,
+ useClearBrowserCache
+} from 'react-clear-browser-cache';
+
+function VersionBadge() {
+ const { isLatestVersion, clearCacheAndReload } = useClearBrowserCache();
+ return isLatestVersion ? null : (
+ Update
+ );
}
+
+
+ {({ latestVersion }) => {latestVersion} }
+ ;
```
-## Context value
+| Field | Type | Description |
+| :-------------------- | :-------------------- | :-------------------------------------------------------------------------- |
+| `loading` | `boolean` | A version check is running. |
+| `isLatestVersion` | `boolean` | The running build is the latest one. |
+| `latestVersion` | `string` | Version from the most recent meta file. |
+| `disabled` | `boolean` | Storage failed, so the boundary stopped checking and only renders children. |
+| `clearCacheAndReload` | `() => Promise` | Clear Cache Storage, store the latest version and reload. |
+| `dismissUpdate` | `() => void` | Hide the update prompt until a newer build is found. |
-| Name | Type | More info |
-| :------------------ | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------- |
-| loading | boolean | Indicates request for a new version. |
-| isLatestVersion | boolean | Indicates if the latest version of the app. |
-| latestVersion | string | Latest version of the app. |
-| disabled | boolean | Indicates if the boundary component is disabled, stopped all requests, doesn't handle errors, just render children because of internal errors. |
-| clearCacheAndReload | function | Clear CacheStorage and reload the page. |
+### `UpdatePromptProps`
-## `lazyRetry`
+| Prop | Type | Description |
+| :-------------- | :-------------------- | :-------------------------------------------------------------------------------------------------- |
+| `latestVersion` | `string` | The new build's version. |
+| `reason` | `'poll' \| 'error'` | `'error'` when part of the app failed to load; the boundary shows `fallback` behind the prompt. |
+| `reload` | `() => Promise` | Clear Cache Storage and reload the page. |
+| `dismiss` | `() => void` | Hide until a newer build is found. For `reason: 'error'` the error goes on to your `ErrorBoundary`. |
-Retries a failing dynamic import before giving up:
+### `lazyRetry(fn, attempts = 2, interval = 1000)`
+
+Calls `fn` up to `attempts` times in all, `interval` ms apart, until it resolves; then rejects with the last error.
```tsx
import { lazy } from 'react';
@@ -132,33 +278,103 @@ import { lazyRetry } from 'react-clear-browser-cache';
const Settings = lazy(() => lazyRetry(() => import('./Settings'), 2, 1000));
```
-`lazyRetry(fn, retries = 2, interval = 1000)` calls `fn`, and while it rejects
-calls it again up to `retries` more times, `interval` ms apart.
+### `createErrorChecker(name, pattern)`
+
+Builds an error checker that matches an error's `name` and a `RegExp` on its message:
+
+```tsx
+import { createErrorChecker } from 'react-clear-browser-cache';
+
+const errorCheckers = [
+ createErrorChecker('Error', /Failed to load module script/)
+];
+```
+
+Recognised out of the box: webpack `ChunkLoadError` and CSS chunk errors, Vite CSS preload errors, native `import()` failures in Chrome, Firefox and Safari, and `SyntaxError: Unexpected token '<'`.
+
+## Recipes
+
+**App under a sub-path (Vite `base`, GitHub Pages).** Point `filename` at the base URL:
+
+```tsx
+
+```
+
+**Version from your CI instead of a random UUID.** Any JSON with a `version` string works, e.g. `echo "{\"version\":\"$GITHUB_SHA\"}" > public/meta.json`.
+
+**Reload on the next route change.** A route change replaces the screen anyway, so it is a quiet moment to switch builds. `clearCacheAndReload` does not ask `canReload`, so check your own guard here:
+
+```tsx
+function ReloadOnNavigate() {
+ const { isLatestVersion, clearCacheAndReload } = useClearBrowserCache();
+ const { pathname } = useLocation(); // react-router
+ const previous = useRef(pathname);
+
+ useEffect(() => {
+ // Only a real route change counts, not the build turning stale.
+ if (pathname !== previous.current && !isLatestVersion && !formIsDirty()) {
+ clearCacheAndReload();
+ }
+ previous.current = pathname;
+ }, [pathname, isLatestVersion, clearCacheAndReload]);
+
+ return null;
+}
+```
+
+**Show the update somewhere other than a dialog.** Leave `prompt` out and render a banner from `isLatestVersion` with the hook.
+
+## Troubleshooting
+
+
+The app never notices a new build
+
+Open `meta.json` in the browser. It must be the JSON file, not your `index.html`: an SPA fallback that rewrites unknown paths to `index.html` hides a missing file (`debug` reports it). Check that the `prebuild` script ran in CI, and that `duration` is set if you expect polling.
+
+
+
+
+It reloads, but the old build comes back
-## Migrating from 0.2
+Something in front of the browser cached the HTML. Serve `index.html` and `meta.json` with `Cache-Control: no-cache`; hashed assets can stay immutable.
-- React 18 or newer is required, and the package ships ESM and CommonJS builds
- with type declarations through `exports`.
-- `lazyRetry(fn, retries)` now makes `retries` extra attempts (the default is
- three attempts in total, it used to be two), and `retries = 0` no longer
- retries forever.
-- The stored version is only updated when the cache is actually cleared, so
- `isLatestVersion` stays `false` until the user reloads through
- `clearCacheAndReload`.
-- `storage` is typed as `{ get, set }`; the `@ts-ignore` the old type needed can
- go.
+
-## Development
+
+A chunk error still reaches my error boundary
-Use Node.js 24 (`.nvmrc`) and pnpm.
+That happens when the build has not changed — the boundary only recovers when a newer version is out. If your bundler words the error differently, add a checker with `createErrorChecker`.
+
+
+
+## Upgrading from 0.x
+
+Code written for 0.2 keeps working: the props, `useClearBrowserCache`, ``, `createErrorChecker`, `lazyRetry` and the `generate-build-meta` CLI are unchanged, and `auto` still reloads (prefer `onUpdate='reload'`). What changed:
+
+- **React 18 or newer** is required (`peerDependencies`). React 16 and 17 users stay on 0.2.
+- **Package entry points** go through `exports` (ESM `.mjs`, CommonJS `.cjs`, types for both). Deep imports such as `react-clear-browser-cache/dist/index.js` no longer resolve; import from the package name.
+- **The stored version is saved only when the cache is actually cleared**, so `isLatestVersion` stays `false` until the user reloads through `clearCacheAndReload`. 0.2 saved it on every check and could report a stale tab as current.
+- **`lazyRetry(fn, 0)`** makes one attempt; 0.2 retried forever.
+- **`debug`** receives `error` typed as `unknown` (anything can be thrown) and always receives `state`.
+- **`storage`** is typed as `{ get, set }`; the `@ts-ignore` the old `Storage` type needed can go.
+- **Server rendering**: storage is read after mount, so the first render shows `fallback` (or the app with `blocking={false}`) on the server and the client alike.
+
+See [CHANGELOG.md](CHANGELOG.md) for everything new.
+
+## Contributing
+
+Issues and pull requests are welcome. Use Node.js 24 (`.nvmrc`) and pnpm:
```bash
pnpm install
pnpm test # lint, typecheck, unit tests, build, e2e
pnpm test:unit # Vitest
pnpm test:e2e # Playwright against the example app in Chromium, Firefox and WebKit
+pnpm demo:dev # the GitHub Pages demo, with deploys simulated in the browser
```
+The demo is the example app built with `VITE_DEMO=1`. GitHub Pages serves static files only, so a fetch shim answers `meta.json` with a version kept in localStorage; the version check, cache clearing and reload are the library's own. `.github/workflows/pages.yml` publishes it on every push to `master`.
+
## License
MIT © [arenuzzz](https://github.com/arenuzzz)
diff --git a/docs/demo-light.png b/docs/demo-light.png
new file mode 100644
index 0000000..174a19f
Binary files /dev/null and b/docs/demo-light.png differ
diff --git a/e2e/clearBrowserCache.spec.ts b/e2e/clearBrowserCache.spec.ts
index 11c0327..96ada41 100644
--- a/e2e/clearBrowserCache.spec.ts
+++ b/e2e/clearBrowserCache.spec.ts
@@ -156,3 +156,75 @@ test('an SPA fallback answering meta.json with HTML does not block or loop', asy
await expect(loads(page)).toHaveText('1');
expect(await storedVersion(page)).toBeNull();
});
+
+test.describe('update prompt', () => {
+ test('polling opens a modal dialog that Escape dismisses until a newer build', async ({
+ page
+ }) => {
+ const server = await deployServer(page, 'v1');
+ await page.goto('/?prompt=1&duration=200');
+ await expect(page.getByTestId('lazy')).toBeVisible();
+
+ server.deploy('v2');
+
+ const dialog = page.getByRole('dialog', {
+ name: 'A new version is available'
+ });
+ await expect(dialog).toBeVisible();
+ await expect(dialog.getByRole('button', { name: 'Reload' })).toBeFocused();
+ // Modal: the page behind is inert.
+ expect(
+ await page.evaluate(() =>
+ document.querySelector('dialog')?.matches(':modal')
+ )
+ ).toBe(true);
+
+ await page.keyboard.press('Escape');
+ await expect(dialog).toBeHidden();
+ await page.waitForTimeout(600);
+ await expect(dialog).toBeHidden();
+ await expect(loads(page)).toHaveText('1');
+
+ server.deploy('v3');
+ await expect(dialog).toBeVisible();
+ });
+
+ test('Reload in the dialog clears the cache and loads the new build', async ({
+ page
+ }) => {
+ const server = await deployServer(page, 'v1');
+ await page.goto('/?prompt=1&duration=200');
+ await expect(loads(page)).toHaveText('1');
+
+ server.deploy('v2');
+ await page.getByRole('button', { name: 'Reload' }).click();
+
+ await expect(loads(page)).toHaveText('2');
+ await expect(page.getByRole('dialog')).toBeHidden();
+ expect(await storedVersion(page)).toBe('v2');
+ });
+
+ test('promptOnChunkError asks before reloading when a chunk is gone', async ({
+ page
+ }) => {
+ const server = await deployServer(page, 'v1');
+ await page.goto('/?prompt=1&promptOnChunkError=1');
+ await expect(page.getByTestId('lazy')).toBeVisible();
+
+ server.deploy('v1', 'v2');
+ await breakLazyChunk(page, 1);
+ await page.reload();
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toContainText('could not load');
+ expect(await page.evaluate(() => sessionStorage.getItem('loads'))).toBe(
+ '2'
+ );
+
+ await dialog.getByRole('button', { name: 'Reload' }).click();
+
+ await expect(loads(page)).toHaveText('3');
+ await expect(page.getByTestId('lazy')).toBeVisible();
+ expect(await storedVersion(page)).toBe('v2');
+ });
+});
diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts
new file mode 100644
index 0000000..50a2736
--- /dev/null
+++ b/e2e/demo.spec.ts
@@ -0,0 +1,49 @@
+import { expect, test } from '@playwright/test';
+
+test('a simulated deploy is offered through the dialog and applied on Reload', async ({
+ page
+}) => {
+ await page.goto('./');
+ await expect(page.getByTestId('stored')).toHaveText('v1');
+
+ await page.getByRole('button', { name: 'Deploy new version' }).click();
+ await expect(page.getByTestId('deployed')).toHaveText('v2');
+
+ const dialog = page.getByRole('dialog', {
+ name: 'A new version is available'
+ });
+ await expect(dialog).toBeVisible({ timeout: 10_000 });
+ await dialog.getByRole('button', { name: 'Reload' }).click();
+
+ await expect(page.getByTestId('loads')).toHaveText('2');
+ await expect(page.getByTestId('running')).toHaveText('v2');
+ await expect(page.getByTestId('stored')).toHaveText('v2');
+ await expect(page.getByText('Cache Storage is empty')).toBeVisible();
+});
+
+test('a lazy chunk removed by a deploy prompts with the error message', async ({
+ page
+}) => {
+ await page.goto('./?duration=0');
+ await page.getByRole('button', { name: 'Deploy new version' }).click();
+ await page.getByRole('button', { name: 'Open reports (lazy chunk)' }).click();
+
+ const dialog = page.getByRole('dialog');
+ await expect(dialog).toContainText('could not load');
+ await dialog.getByRole('button', { name: 'Reload' }).click();
+
+ await expect(page.getByTestId('running')).toHaveText('v2');
+ await page.getByRole('button', { name: 'Open reports (lazy chunk)' }).click();
+ await expect(page.getByTestId('reports')).toHaveText(/v2 chunk/);
+});
+
+test('the custom banner component replaces the dialog', async ({ page }) => {
+ await page.goto('./?prompt=banner&duration=3000');
+ await page.getByRole('button', { name: 'Deploy new version' }).click();
+
+ const banner = page.getByRole('status');
+ await expect(banner).toContainText('v2 is ready', { timeout: 10_000 });
+ await banner.getByRole('button', { name: 'Dismiss' }).click();
+ await expect(banner).toBeHidden();
+ await expect(page.getByTestId('is-latest')).toHaveText('false');
+});
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 4189dbf..7799dbe 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -6,7 +6,13 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
- ignores: ['**/dist', 'coverage', 'playwright-report', 'test-results']
+ ignores: [
+ '**/dist',
+ '**/dist-demo',
+ 'coverage',
+ 'playwright-report',
+ 'test-results'
+ ]
},
js.configs.recommended,
tseslint.configs.recommended,
diff --git a/example/README.md b/example/README.md
index dbe589f..37b8313 100644
--- a/example/README.md
+++ b/example/README.md
@@ -11,7 +11,17 @@ pnpm example:dev
Query parameters configure the boundary, so one build covers every scenario:
-| Parameter | Effect |
-| :--------- | :-------------------------------------- |
-| `auto=1` | Reload as soon as a new build is found. |
-| `duration` | Polling interval in ms. |
+| Parameter | Effect |
+| :---------------- | :-------------------------------------------------- |
+| `auto=1` | Reload as soon as a new build is found. |
+| `duration` | Polling interval in ms. |
+| `prompt=1` | Show the built-in `UpdatePrompt` dialog. |
+| `promptOnError=1` | Prompt instead of reloading on a stale-chunk error. |
+
+## GitHub Pages demo
+
+`pnpm demo:dev` and `pnpm demo:build` set `VITE_DEMO=1`, which swaps the entry
+for `src/demo`: a playground with a **Deploy new version** button, live boundary
+props, the tab's state and an event log. A fetch shim stands in for the server,
+since Pages serves static files only. `e2e/demo.spec.ts` covers it under the
+same `/react-clear-browser-cache/` sub-path that Pages uses.
diff --git a/example/src/UpdateDialog.css b/example/src/UpdateDialog.css
new file mode 100644
index 0000000..44a5ff7
--- /dev/null
+++ b/example/src/UpdateDialog.css
@@ -0,0 +1,123 @@
+/* Falls back to its own colors where the page defines no design tokens. */
+.update-dialog {
+ --dialog-accent: var(--accent, #2f6fde);
+ --dialog-accent-text: var(--accent-text, #fff);
+ --dialog-surface: var(--surface, #fff);
+ --dialog-text: var(--text, #1b1f24);
+ --dialog-muted: var(--muted, #5b6573);
+ --dialog-border: var(--border, #dfe3e8);
+ --dialog-soft: var(--code-bg, #eef1f5);
+
+ width: min(24rem, calc(100vw - 32px));
+ padding: 1.5rem;
+ border: 1px solid var(--dialog-border);
+ border-radius: 18px;
+ background: var(--dialog-surface);
+ color: var(--dialog-text);
+ box-shadow:
+ 0 24px 60px rgb(16 24 40 / 0.22),
+ 0 2px 6px rgb(16 24 40 / 0.08);
+ text-align: center;
+}
+
+.update-dialog[open] {
+ animation: update-dialog-in 0.18s ease-out;
+}
+
+.update-dialog::backdrop {
+ background: rgb(15 20 30 / 0.4);
+ backdrop-filter: blur(3px);
+}
+
+@keyframes update-dialog-in {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.98);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .update-dialog[open] {
+ animation: none;
+ }
+}
+
+.update-dialog-icon {
+ display: grid;
+ place-items: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ margin: 0 auto 0.9rem;
+ border-radius: 50%;
+ background: color-mix(in srgb, var(--dialog-accent) 12%, transparent);
+ color: var(--dialog-accent);
+}
+
+.update-dialog[data-reason='error'] .update-dialog-icon {
+ background: color-mix(in srgb, #d97706 14%, transparent);
+ color: #d97706;
+}
+
+/* Doubled class: the dialog renders wherever the boundary is, so it must beat
+ heading styles of the surrounding page. */
+.update-dialog.update-dialog h2 {
+ margin: 0 0 0.4rem;
+ color: var(--dialog-text);
+ font-size: 1.15rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ text-transform: none;
+}
+
+.update-dialog p {
+ margin: 0;
+ color: var(--dialog-muted);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.update-dialog-version {
+ margin-top: 0.75rem !important;
+ font-size: 0.8rem !important;
+}
+
+.update-dialog-version code {
+ padding: 0.1rem 0.4rem;
+ border-radius: 6px;
+ background: var(--dialog-soft);
+ color: var(--dialog-text);
+}
+
+.update-dialog-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+}
+
+.update-dialog-actions button {
+ padding: 0.6rem 1rem;
+ border: 1px solid var(--dialog-border);
+ border-radius: 10px;
+ background: var(--dialog-surface);
+ color: var(--dialog-text);
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.update-dialog-actions button:disabled {
+ cursor: progress;
+ opacity: 0.7;
+}
+
+.update-dialog-actions .update-dialog-primary {
+ border-color: var(--dialog-accent);
+ background: var(--dialog-accent);
+ color: var(--dialog-accent-text);
+}
+
+.update-dialog-actions button:focus-visible {
+ outline: 2px solid var(--dialog-accent);
+ outline-offset: 2px;
+}
diff --git a/example/src/UpdateDialog.tsx b/example/src/UpdateDialog.tsx
new file mode 100644
index 0000000..23d8601
--- /dev/null
+++ b/example/src/UpdateDialog.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useId, useRef, useState } from 'react';
+import type {
+ UpdatePromptProps,
+ UpdatePromptReason
+} from 'react-clear-browser-cache';
+
+import './UpdateDialog.css';
+
+const messages: Record = {
+ poll: 'Reload to get the latest fixes and features. It only takes a second.',
+ error:
+ 'Part of the app could not load because a new version was released. Reload to continue.'
+};
+
+// An example prompt to copy into your app: the library ships none, so the
+// prompt can match your design system.
+export function UpdateDialog({
+ latestVersion,
+ reason,
+ reload,
+ dismiss
+}: UpdatePromptProps) {
+ const dialogRef = useRef(null);
+ const reloadRef = useRef(null);
+ const [reloading, setReloading] = useState(false);
+ const titleId = useId();
+ const messageId = useId();
+
+ // Declared before the opening effect so it records focus from before the
+ // dialog took it. The dialog is removed rather than closed, and removal does
+ // not hand focus back the way close() does.
+ useEffect(() => {
+ const previouslyFocused = document.activeElement;
+
+ return () => {
+ if (previouslyFocused instanceof HTMLElement) {
+ previouslyFocused.focus();
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+
+ // StrictMode's second run finds it open already.
+ if (dialog && !dialog.open) {
+ dialog.showModal();
+ }
+
+ // React applies autoFocus before the dialog opens, when nothing inside it
+ // can take focus, and opening then focuses the first button instead.
+ reloadRef.current?.focus();
+ }, []);
+
+ // Escape while the reload is under way would otherwise hide the prompt, or
+ // hand the error to the parent boundary, just before the page reloads.
+ const dismissUnlessReloading = () => {
+ if (!reloading) {
+ dismiss();
+ }
+ };
+
+ return (
+ {
+ event.preventDefault();
+ dismissUnlessReloading();
+ }}
+ >
+
+ A new version is available
+ {messages[reason]}
+
+ Version {latestVersion}
+
+
+
+ Later
+
+ {
+ setReloading(true);
+ reload();
+ }}
+ >
+ Reload
+
+
+
+ );
+}
diff --git a/example/src/demo/Demo.tsx b/example/src/demo/Demo.tsx
new file mode 100644
index 0000000..c3b4fe4
--- /dev/null
+++ b/example/src/demo/Demo.tsx
@@ -0,0 +1,522 @@
+import {
+ Component,
+ Suspense,
+ lazy,
+ useEffect,
+ useState,
+ useSyncExternalStore,
+ type ReactNode
+} from 'react';
+import {
+ ClearBrowserCacheBoundary,
+ useClearBrowserCache
+} from 'react-clear-browser-cache';
+
+import { prompts, type PromptChoice } from './prompts';
+import {
+ cacheNames,
+ clearLog,
+ debug,
+ deploy,
+ deployedVersion,
+ failingChunkError,
+ getLog,
+ pageLoad,
+ resetDemo,
+ runningVersion,
+ storedVersion,
+ subscribe
+} from './server';
+
+const Reports = lazy(() =>
+ deployedVersion() === runningVersion
+ ? import('./Reports')
+ : Promise.reject(failingChunkError())
+);
+
+type UpdateMode = 'prompt' | 'reload' | 'flag';
+
+type Settings = {
+ duration: number;
+ onUpdate: UpdateMode;
+ prompt: PromptChoice;
+ promptOnChunkError: boolean;
+ reloadWhenHidden: boolean;
+ blocking: boolean;
+};
+
+type Choice = { value: T; label: string; hint?: string };
+
+const durations: Choice[] = [
+ { value: 0, label: 'Off' },
+ { value: 3000, label: '3 s' },
+ { value: 10000, label: '10 s' }
+];
+
+const updateModes: Choice[] = [
+ { value: 'prompt', label: 'prompt', hint: 'ask the user' },
+ { value: 'reload', label: 'reload', hint: 'reload at once' },
+ { value: 'flag', label: 'flag', hint: 'only set isLatestVersion' }
+];
+
+const promptChoices: Choice[] = [
+ { value: 'dialog', label: 'Dialog', hint: 'modal ' },
+ { value: 'banner', label: 'Banner', hint: 'toast at the bottom' }
+];
+
+function pick(choices: Choice[], raw: unknown, fallback: T): T {
+ return choices.find((c) => String(c.value) === raw)?.value ?? fallback;
+}
+
+function readSettings(): Settings {
+ const params = new URLSearchParams(location.search);
+
+ return {
+ duration: pick(durations, params.get('duration'), 3000),
+ onUpdate: pick(updateModes, params.get('onUpdate'), 'prompt'),
+ prompt: pick(promptChoices, params.get('prompt'), 'dialog'),
+ promptOnChunkError: params.get('promptOnChunkError') !== '0',
+ reloadWhenHidden: params.get('reloadWhenHidden') === '1',
+ blocking: params.get('blocking') !== '0'
+ };
+}
+
+// Kept in the URL so the settings survive the reloads the demo is about.
+function writeSettings(settings: Settings) {
+ const params = new URLSearchParams({
+ duration: String(settings.duration),
+ onUpdate: settings.onUpdate,
+ prompt: settings.prompt,
+ promptOnChunkError: settings.promptOnChunkError ? '1' : '0',
+ reloadWhenHidden: settings.reloadWhenHidden ? '1' : '0',
+ blocking: settings.blocking ? '1' : '0'
+ });
+ history.replaceState(null, '', `?${params}`);
+}
+
+function promptInUse(settings: Settings) {
+ return settings.onUpdate === 'prompt' || settings.promptOnChunkError;
+}
+
+function useServer() {
+ return useSyncExternalStore(subscribe, deployedVersion);
+}
+
+function useTick(ms: number) {
+ const [, setTick] = useState(0);
+
+ useEffect(() => {
+ const id = setInterval(() => setTick((tick) => tick + 1), ms);
+ return () => clearInterval(id);
+ }, [ms]);
+}
+
+function useCacheNames() {
+ const [names, setNames] = useState([]);
+
+ useEffect(() => {
+ let active = true;
+ const refresh = () => {
+ void cacheNames().then((next) => active && setNames(next));
+ };
+
+ refresh();
+ const id = setInterval(refresh, 1000);
+ const unsubscribe = subscribe(refresh);
+
+ return () => {
+ active = false;
+ clearInterval(id);
+ unsubscribe();
+ };
+ }, []);
+
+ return names;
+}
+
+class AppErrorBoundary extends Component<
+ { children: ReactNode },
+ { error: Error | null }
+> {
+ state = { error: null as Error | null };
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ Your app's error boundary caught it:
+ {this.state.error.message}
+ location.reload()}>
+ Reload page
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+function Status() {
+ const { isLatestVersion, latestVersion, clearCacheAndReload } =
+ useClearBrowserCache();
+ const names = useCacheNames();
+ const stored = storedVersion();
+ useTick(500);
+
+ return (
+ <>
+
+ Running build
+ {runningVersion}
+
+ Stored version APP_VERSION
+
+ {stored ?? '—'}
+
+ isLatestVersion
+
+
+ {String(isLatestVersion)}
+
+
+ latestVersion
+
+ {latestVersion || '—'}
+ Page loads this session
+ {pageLoad}
+ Cache Storage
+
+ {names === null
+ ? 'unavailable'
+ : names.length
+ ? names.join(', ')
+ : 'empty'}
+
+
+ {!isLatestVersion && (
+
+ Update now (from the hook)
+
+ )}
+ >
+ );
+}
+
+function ReportsSection() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ setOpen(true)} disabled={open}>
+ Open reports (lazy chunk)
+
+ {open && (
+ Loading reports…}>
+
+
+ )}
+
+ );
+}
+
+function RadioGroup({
+ name,
+ legend,
+ choices,
+ value,
+ disabled,
+ onChange
+}: {
+ name: string;
+ legend: ReactNode;
+ choices: Choice[];
+ value: T;
+ disabled?: boolean;
+ onChange: (value: T) => void;
+}) {
+ return (
+
+ {legend}
+
+ {choices.map((choice) => (
+
+ onChange(choice.value)}
+ />
+
+ {choice.label}
+ {choice.hint && {choice.hint} }
+
+
+ ))}
+
+
+ );
+}
+
+function Toggle({
+ checked,
+ disabled,
+ onChange,
+ name,
+ hint
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ onChange: (checked: boolean) => void;
+ name: ReactNode;
+ hint: string;
+}) {
+ return (
+
+ onChange(e.target.checked)}
+ />
+
+ {name}
+ {hint}
+
+
+ );
+}
+
+function Settings({
+ settings,
+ onChange
+}: {
+ settings: Settings;
+ onChange: (settings: Settings) => void;
+}) {
+ const set = (patch: Partial) => onChange({ ...settings, ...patch });
+
+ return (
+
+
+ duration}
+ choices={durations}
+ value={settings.duration}
+ onChange={(duration) => set({ duration })}
+ />
+ onUpdate}
+ choices={updateModes}
+ value={settings.onUpdate}
+ onChange={(onUpdate) => set({ onUpdate })}
+ />
+ prompt}
+ choices={promptChoices}
+ value={settings.prompt}
+ disabled={!promptInUse(settings)}
+ onChange={(prompt) => set({ prompt })}
+ />
+
+
+ set({ promptOnChunkError })}
+ />
+ set({ reloadWhenHidden })}
+ />
+ set({ blocking: !nonBlocking })}
+ />
+
+
+ );
+}
+
+function snippet(settings: Settings) {
+ const props = [
+ "filename='/react-clear-browser-cache/meta.json'",
+ settings.duration ? `duration={${settings.duration}}` : null,
+ settings.onUpdate === 'prompt' ? null : `onUpdate='${settings.onUpdate}'`,
+ promptInUse(settings)
+ ? `prompt={${settings.prompt === 'dialog' ? 'UpdateDialog' : 'Banner'}}`
+ : null,
+ settings.promptOnChunkError ? 'promptOnChunkError' : null,
+ settings.reloadWhenHidden ? 'reloadWhenHidden' : null,
+ settings.blocking ? null : 'blocking={false}'
+ ].filter(Boolean);
+
+ return ` `\n ${p}`).join('')}
+>
+
+ `;
+}
+
+function Log() {
+ const entries = useSyncExternalStore(subscribe, getLog);
+
+ return (
+
+
+ Event log
+
+ Clear
+
+
+
+ {entries
+ .slice()
+ .reverse()
+ .map((entry) => (
+
+ {entry.time}
+ {entry.text}
+
+ ))}
+
+
+ );
+}
+
+export default function Demo() {
+ const [settings, setSettings] = useState(readSettings);
+ const deployed = useServer();
+
+ const change = (next: Settings) => {
+ setSettings(next);
+ writeSettings(next);
+ };
+
+ const stale = deployed !== runningVersion;
+
+ return (
+
+
+ Live demo · deploys simulated in your browser
+ react-clear-browser-cache
+
+ Users keep old tabs open. When you ship, their page still runs the
+ last build and its lazy chunks are gone from the server. This boundary
+ notices, empties Cache Storage and reloads, or asks first.
+
+
+
+ GitHub
+
+
+ npm
+
+
+
+
+
+
+ Deploy a new version. The server moves on; this tab
+ still runs {runningVersion}.
+
+
+ Wait for the next poll, or open the reports: their
+ chunk is gone, so loading them fails.
+
+
+ Reload from the prompt and watch the log. Open a
+ second tab to see deploys reach it too.
+
+
+
+
+
+ Server
+
+
+ Deployed
+
+ {deployed}
+
+
+
+ →
+
+
+ This tab runs
+
+ {runningVersion}
+
+
+
+
+
+ Deploy new version
+
+ void resetDemo()}>
+ Reset demo
+
+
+
+
+
+ This tab
+
+ Checking for a new build…}
+ duration={settings.duration || undefined}
+ onUpdate={settings.onUpdate}
+ prompt={
+ promptInUse(settings) ? prompts[settings.prompt] : undefined
+ }
+ promptOnChunkError={settings.promptOnChunkError}
+ reloadWhenHidden={settings.reloadWhenHidden}
+ blocking={settings.blocking}
+ filename={`${import.meta.env.BASE_URL}meta.json`}
+ debug={debug}
+ >
+
+
+
+
+
+
+
+
+
+ Boundary props
+
+
+
+
+ {snippet(settings)}
+
+
+ Polling pauses while this tab is in the background. When it gets
+ focus back after a whole interval, it checks at once.
+
+
+
+
+
+
+ );
+}
diff --git a/example/src/demo/Reports.tsx b/example/src/demo/Reports.tsx
new file mode 100644
index 0000000..84c2fe9
--- /dev/null
+++ b/example/src/demo/Reports.tsx
@@ -0,0 +1,9 @@
+import { runningVersion } from './server';
+
+export default function Reports() {
+ return (
+
+ Reports loaded from the {runningVersion} chunk.
+
+ );
+}
diff --git a/example/src/demo/demo.css b/example/src/demo/demo.css
new file mode 100644
index 0000000..2cd1b32
--- /dev/null
+++ b/example/src/demo/demo.css
@@ -0,0 +1,654 @@
+:root {
+ color-scheme: light dark;
+ --bg: #f6f7f9;
+ --surface: #ffffff;
+ --text: #1b1f24;
+ --muted: #5b6470;
+ --border: #dde1e6;
+ --accent: #2f6fde;
+ --accent-text: #ffffff;
+ --ok: #1f7a46;
+ --warn: #b25b00;
+ --error: #c0362c;
+ --code-bg: #eef1f5;
+ --accent-soft: rgb(47 111 222 / 0.1);
+ --radius: 14px;
+ --shadow: 0 1px 2px rgb(16 24 40 / 0.05), 0 8px 24px rgb(16 24 40 / 0.06);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+ --bg: #111418;
+ --surface: #1a1e24;
+ --text: #e6e9ed;
+ --muted: #9aa4b1;
+ --border: #2c323b;
+ --accent: #6b9cff;
+ --accent-text: #0b1220;
+ --ok: #5cc98a;
+ --warn: #f0a24a;
+ --error: #ff7b70;
+ --code-bg: #232933;
+ --accent-soft: rgb(107 156 255 / 0.14);
+ --shadow: none;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font:
+ 14px/1.5 system-ui,
+ -apple-system,
+ 'Segoe UI',
+ sans-serif;
+}
+
+code,
+pre {
+ font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
+ font-size: 0.9em;
+}
+
+a {
+ color: var(--accent);
+}
+
+h1,
+h2 {
+ line-height: 1.2;
+ margin: 0 0 0.5rem;
+}
+
+h2 {
+ font-size: 1.05rem;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ padding: 0.5rem 0.95rem;
+ cursor: pointer;
+ transition:
+ transform 0.12s,
+ border-color 0.12s;
+}
+
+button:not(:disabled):hover {
+ border-color: var(--accent);
+}
+
+button:not(:disabled):active {
+ transform: translateY(1px);
+}
+
+button:disabled {
+ cursor: default;
+ opacity: 0.55;
+}
+
+button.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--accent-text);
+ font-weight: 600;
+}
+
+button.link {
+ border: none;
+ background: none;
+ color: var(--accent);
+ padding: 0;
+}
+
+:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.page {
+ max-width: 1200px;
+ margin: 0 auto;
+ /* Room to scroll past the fixed banner prompt. */
+ padding: 1.75rem 16px 6rem;
+}
+
+.hero {
+ max-width: 68ch;
+}
+
+.eyebrow {
+ display: inline-block;
+ margin: 0 0 0.75rem;
+ padding: 0.2rem 0.65rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-size: 0.8rem;
+ font-weight: 600;
+}
+
+.hero h1 {
+ font-size: clamp(1.5rem, 4vw, 2rem);
+ letter-spacing: -0.02em;
+}
+
+.lede {
+ color: var(--muted);
+ font-size: 0.95rem;
+ margin: 0 0 0.75rem;
+}
+
+.hero nav {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.pill {
+ padding: 0.3rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface);
+ text-decoration: none;
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+.pill:hover {
+ border-color: var(--accent);
+}
+
+.steps {
+ counter-reset: step;
+ list-style: none;
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin: 1.25rem 0 1rem;
+ padding: 0;
+}
+
+@media (max-width: 760px) {
+ .steps {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.steps li {
+ counter-increment: step;
+ position: relative;
+ padding: 0.6rem 0.8rem 0.6rem 2.6rem;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.steps li::before {
+ content: counter(step);
+ position: absolute;
+ left: 0.75rem;
+ top: 0.6rem;
+ width: 1.35rem;
+ height: 1.35rem;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--accent-text);
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
+.steps strong {
+ color: var(--text);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.85rem;
+ align-items: stretch;
+}
+
+.props-card {
+ grid-column: 1 / -1;
+}
+
+.props-body {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
+ gap: 1.25rem;
+ align-items: start;
+}
+
+.props-body > * {
+ min-width: 0;
+}
+
+@media (max-width: 900px) {
+ .props-body {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+@media (max-width: 1060px) {
+ .grid {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .log-card {
+ grid-column: 1 / -1;
+ }
+
+ .log-card .log {
+ flex-basis: auto;
+ max-height: 9rem;
+ }
+}
+
+@media (max-width: 720px) {
+ .grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.stack {
+ display: grid;
+ gap: 1rem;
+ min-width: 0;
+}
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.9rem 1rem;
+ min-width: 0;
+ box-shadow: var(--shadow);
+}
+
+.card h2 {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--muted);
+ margin-bottom: 0.7rem;
+}
+
+.versions {
+ display: flex;
+ align-items: center;
+ gap: 0.9rem;
+ margin-bottom: 1rem;
+}
+
+.versions > div {
+ display: grid;
+ gap: 0.25rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.arrow {
+ color: var(--muted);
+ font-size: 1.2rem;
+ padding-top: 1rem;
+}
+
+.chip {
+ justify-self: start;
+ padding: 0.15rem 0.6rem;
+ border-radius: 8px;
+ font:
+ 700 1.05rem/1.3 ui-monospace,
+ 'SF Mono',
+ Menlo,
+ Consolas,
+ monospace;
+ border: 1px solid currentColor;
+}
+
+.chip.accent {
+ color: var(--accent);
+}
+
+.chip.ok {
+ color: var(--ok);
+}
+
+.chip.warn {
+ color: var(--warn);
+}
+
+.row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.settings {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem 1rem;
+}
+
+@media (max-width: 560px) {
+ .settings {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.radio-group {
+ border: 0;
+ margin: 0;
+ padding: 0;
+ min-width: 0;
+}
+
+.radio-group legend {
+ padding: 0;
+ margin-bottom: 0.35rem;
+ font-size: 0.85rem;
+}
+
+.radio-group:disabled {
+ opacity: 0.45;
+}
+
+.segments {
+ display: flex;
+ padding: 3px;
+ gap: 3px;
+ border-radius: 11px;
+ background: var(--code-bg);
+}
+
+.segment {
+ flex: 1 1 0;
+ min-width: 0;
+ position: relative;
+ cursor: pointer;
+}
+
+.radio-group:disabled .segment {
+ cursor: default;
+}
+
+.segment input {
+ position: absolute;
+ opacity: 0;
+ inset: 0;
+ margin: 0;
+ cursor: inherit;
+}
+
+.segment > span {
+ display: grid;
+ justify-items: center;
+ gap: 0.05rem;
+ padding: 0.3rem 0.3rem;
+ border-radius: 8px;
+ text-align: center;
+ font-size: 0.8rem;
+ transition: background 0.15s;
+}
+
+.segment strong {
+ font-weight: 600;
+}
+
+.segment small {
+ color: var(--muted);
+ font-size: 0.68rem;
+ line-height: 1.2;
+}
+
+.segment input:checked + span {
+ background: var(--surface);
+ box-shadow: 0 1px 3px rgb(16 24 40 / 0.15);
+ color: var(--accent);
+}
+
+.segment input:focus-visible + span {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.radios {
+ display: grid;
+ gap: 0.7rem;
+ align-content: start;
+ min-width: 0;
+}
+
+.toggles {
+ display: grid;
+ gap: 0.15rem;
+ align-content: start;
+}
+
+.toggle {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6rem;
+ padding: 0.3rem 0.45rem;
+ border-radius: 9px;
+ cursor: pointer;
+}
+
+.toggle:hover {
+ background: var(--code-bg);
+}
+
+.toggle input {
+ appearance: none;
+ flex: none;
+ width: 2.1rem;
+ height: 1.2rem;
+ margin: 0.1rem 0 0;
+ border-radius: 999px;
+ background: var(--border);
+ position: relative;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+
+.toggle input::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: calc(1.2rem - 4px);
+ height: calc(1.2rem - 4px);
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
+ transition: transform 0.15s;
+}
+
+.toggle input:checked {
+ background: var(--accent);
+}
+
+.toggle input:checked::after {
+ transform: translateX(0.9rem);
+}
+
+.toggle > span {
+ display: grid;
+}
+
+.toggle small {
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.snippet {
+ background: var(--code-bg);
+ border-radius: 10px;
+ padding: 0.8rem 0.9rem;
+ margin: 0;
+ overflow-x: auto;
+ font-size: 0.78rem;
+ line-height: 1.5;
+}
+
+.note {
+ color: var(--muted);
+ font-size: 0.85rem;
+ margin: 0.75rem 0 0;
+}
+
+.status {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.4rem 1rem;
+ margin: 0 0 0.75rem;
+ font-size: 0.85rem;
+}
+
+.status dt {
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.status dd {
+ margin: 0;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.ok {
+ color: var(--ok);
+}
+
+.warn {
+ color: var(--warn);
+}
+
+.reports-section {
+ margin-top: 1rem;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border);
+}
+
+.reports {
+ color: var(--ok);
+}
+
+.fallback {
+ color: var(--muted);
+}
+
+.crash {
+ display: grid;
+ gap: 0.5rem;
+ justify-items: start;
+ border: 1px solid var(--error);
+ border-radius: 8px;
+ padding: 0.75rem;
+ color: var(--error);
+}
+
+.crash code {
+ overflow-wrap: anywhere;
+}
+
+.log-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.log-card header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.log {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ /* A zero basis keeps long logs from growing the row; the other cards set its height. */
+ flex: 1 1 0;
+ min-height: 0;
+ overflow-y: auto;
+ font-size: 0.8rem;
+}
+
+.log li {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 0.6rem;
+ padding: 0.15rem 0 0.15rem 0.5rem;
+ border-left: 3px solid var(--border);
+}
+
+.log li + li {
+ margin-top: 0.1rem;
+}
+
+.log time {
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.log .deploy {
+ border-color: var(--accent);
+}
+
+.log .load {
+ border-color: var(--text);
+}
+
+.log .cache,
+.log .state {
+ border-color: var(--ok);
+}
+
+.log .error {
+ border-color: var(--error);
+ color: var(--error);
+}
+
+.banner {
+ position: fixed;
+ z-index: 10;
+ left: 16px;
+ right: 16px;
+ bottom: 16px;
+ margin: 0 auto;
+ max-width: 34rem;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.6rem;
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ background: var(--text);
+ color: var(--bg);
+ box-shadow: 0 10px 30px rgb(0 0 0 / 0.3);
+}
+
+.banner span {
+ flex: 1 1 12rem;
+}
+
+.banner button {
+ background: transparent;
+ border-color: currentColor;
+}
+
+.banner button.primary {
+ background: var(--accent);
+ border-color: var(--accent);
+}
diff --git a/example/src/demo/main.tsx b/example/src/demo/main.tsx
new file mode 100644
index 0000000..d9ba16c
--- /dev/null
+++ b/example/src/demo/main.tsx
@@ -0,0 +1,14 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import Demo from './Demo';
+import { start } from './server';
+import './demo.css';
+
+start();
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/example/src/demo/prompts.tsx b/example/src/demo/prompts.tsx
new file mode 100644
index 0000000..e990547
--- /dev/null
+++ b/example/src/demo/prompts.tsx
@@ -0,0 +1,33 @@
+import type { UpdatePromptProps } from 'react-clear-browser-cache';
+
+import { UpdateDialog } from '../UpdateDialog';
+
+export function BannerPrompt({
+ latestVersion,
+ reason,
+ reload,
+ dismiss
+}: UpdatePromptProps) {
+ return (
+
+
+ {reason === 'error'
+ ? `Part of the page needs ${latestVersion}.`
+ : `${latestVersion} is ready.`}
+
+
+ Refresh
+
+
+ Dismiss
+
+
+ );
+}
+
+export const prompts = {
+ dialog: UpdateDialog,
+ banner: BannerPrompt
+};
+
+export type PromptChoice = keyof typeof prompts;
diff --git a/example/src/demo/server.ts b/example/src/demo/server.ts
new file mode 100644
index 0000000..1a9967f
--- /dev/null
+++ b/example/src/demo/server.ts
@@ -0,0 +1,211 @@
+import type { ClearBrowserCacheDebugFunc } from 'react-clear-browser-cache';
+
+// GitHub Pages only serves static files, so a "deploy" here is a version kept
+// in localStorage and handed out as meta.json by a fetch shim. Everything after
+// that fetch — comparing versions, clearing Cache Storage, reloading — is the
+// library's own code.
+
+export const STORAGE_KEY = 'APP_VERSION';
+const DEPLOYED_KEY = 'demo:deployed';
+const LOG_KEY = 'demo:log';
+const LOADS_KEY = 'demo:loads';
+const CACHE_PREFIX = 'demo-assets-';
+
+export type LogKind = 'load' | 'check' | 'deploy' | 'cache' | 'state' | 'error';
+export type LogEntry = {
+ id: number;
+ time: string;
+ kind: LogKind;
+ text: string;
+};
+
+const listeners = new Set<() => void>();
+
+function notify() {
+ listeners.forEach((listener) => listener());
+}
+
+export function subscribe(listener: () => void) {
+ listeners.add(listener);
+ // Deploys from another tab arrive as storage events.
+ window.addEventListener('storage', listener);
+
+ return () => {
+ listeners.delete(listener);
+ window.removeEventListener('storage', listener);
+ };
+}
+
+export function deployedVersion() {
+ return localStorage.getItem(DEPLOYED_KEY) ?? 'v1';
+}
+
+export function storedVersion() {
+ return localStorage.getItem(STORAGE_KEY);
+}
+
+// What this page was built from: whatever was deployed when it loaded.
+export const runningVersion = deployedVersion();
+
+export const pageLoad = Number(sessionStorage.getItem(LOADS_KEY) ?? 0) + 1;
+sessionStorage.setItem(LOADS_KEY, String(pageLoad));
+
+let entries: LogEntry[] = readLog();
+
+function readLog(): LogEntry[] {
+ try {
+ return JSON.parse(sessionStorage.getItem(LOG_KEY) ?? '[]') as LogEntry[];
+ } catch {
+ return [];
+ }
+}
+
+export function getLog() {
+ return entries;
+}
+
+export function log(kind: LogKind, text: string) {
+ const id = (entries.at(-1)?.id ?? 0) + 1;
+ const time = new Date().toLocaleTimeString([], { hour12: false });
+
+ entries = [...entries, { id, time, kind, text }].slice(-100);
+ sessionStorage.setItem(LOG_KEY, JSON.stringify(entries));
+ notify();
+}
+
+export function clearLog() {
+ entries = [];
+ sessionStorage.removeItem(LOG_KEY);
+ notify();
+}
+
+export function deploy() {
+ const current = Number(/^v(\d+)$/.exec(deployedVersion())?.[1] ?? 1);
+ const next = `v${current + 1}`;
+
+ localStorage.setItem(DEPLOYED_KEY, next);
+ log('deploy', `Deployed ${next}. Its chunks replace the old ones.`);
+}
+
+let resetting = false;
+
+export async function resetDemo() {
+ resetting = true;
+ localStorage.removeItem(DEPLOYED_KEY);
+ sessionStorage.clear();
+ await clearDemoCaches();
+ // Removed last: a poll that lands during the awaits above stores the version
+ // again, and the reset page would then start out already "installed".
+ localStorage.removeItem(STORAGE_KEY);
+ location.replace(location.pathname);
+}
+
+export function failingChunkError() {
+ // Worded like Chrome's, which the default error checkers recognise.
+ return new TypeError(
+ `Failed to fetch dynamically imported module: ${location.origin}${import.meta.env.BASE_URL}assets/Reports-${runningVersion}.js`
+ );
+}
+
+export function installFetchShim() {
+ const realFetch = window.fetch.bind(window);
+
+ window.fetch = (input, init) => {
+ const url = new URL(
+ input instanceof Request ? input.url : String(input),
+ location.href
+ );
+
+ if (!url.pathname.endsWith('/meta.json')) {
+ return realFetch(input, init);
+ }
+
+ const version = deployedVersion();
+ log('check', `GET meta.json → ${version}`);
+
+ return Promise.resolve(
+ new Response(JSON.stringify({ version }), {
+ headers: { 'Content-Type': 'application/json' }
+ })
+ );
+ };
+}
+
+function hasCacheStorage() {
+ return typeof caches !== 'undefined';
+}
+
+export async function cacheNames() {
+ if (!hasCacheStorage()) {
+ return null;
+ }
+
+ return (await caches.keys()).filter((name) => name.startsWith(CACHE_PREFIX));
+}
+
+async function clearDemoCaches() {
+ const names = (await cacheNames()) ?? [];
+ await Promise.all(names.map((name) => caches.delete(name)));
+}
+
+// Plays the part of a service worker's precache, so it is visible whether the
+// library emptied Cache Storage before a reload.
+export async function seedCache() {
+ const names = await cacheNames();
+
+ if (names === null) {
+ log('cache', 'Cache Storage is unavailable here (needs HTTPS).');
+ return;
+ }
+
+ if (pageLoad > 1) {
+ log(
+ 'cache',
+ names.length
+ ? `Cache Storage still holds ${names.join(', ')}.`
+ : 'Cache Storage is empty: it was cleared before the reload.'
+ );
+ }
+
+ const cache = await caches.open(`${CACHE_PREFIX}${runningVersion}`);
+ await cache.put(
+ `${import.meta.env.BASE_URL}assets/app-${runningVersion}.js`,
+ new Response('')
+ );
+ notify();
+}
+
+let lastSummary = '';
+
+export const debug: ClearBrowserCacheDebugFunc = ({ state, error }) => {
+ if (error) {
+ const { name, message } =
+ error instanceof Error ? error : new Error(String(error));
+ log('error', `Caught ${name}: ${message}`);
+ }
+
+ if (state.loading || !state.latestVersion) {
+ return;
+ }
+
+ const summary = state.isLatestVersion
+ ? `Up to date with ${state.latestVersion}.`
+ : `${state.latestVersion} is out; this tab still runs ${runningVersion}.`;
+
+ if (summary !== lastSummary) {
+ lastSummary = summary;
+ log('state', summary);
+ }
+};
+
+export function start() {
+ log('load', `Page load #${pageLoad}, running ${runningVersion}.`);
+ installFetchShim();
+ void seedCache();
+ window.addEventListener('pagehide', () => {
+ // After a reset the log must start empty, not with this entry.
+ if (!resetting) {
+ log('load', 'Page reloading…');
+ }
+ });
+}
diff --git a/example/src/main.tsx b/example/src/main.tsx
index 60f9633..0e9cd07 100644
--- a/example/src/main.tsx
+++ b/example/src/main.tsx
@@ -7,30 +7,38 @@ import {
import App from './App';
import ErrorBoundary from './ErrorBoundary';
+import { UpdateDialog } from './UpdateDialog';
-const params = new URLSearchParams(window.location.search);
+if (import.meta.env.VITE_DEMO === '1') {
+ // The GitHub Pages build, which simulates deploys in the browser.
+ void import('./demo/main');
+} else {
+ const params = new URLSearchParams(window.location.search);
-// Counts real page loads across reloads, so the e2e suite can tell a reload
-// from a re-render.
-const loads = Number(sessionStorage.getItem('loads') ?? 0) + 1;
-sessionStorage.setItem('loads', String(loads));
+ // Counts real page loads across reloads, so the e2e suite can tell a reload
+ // from a re-render.
+ const loads = Number(sessionStorage.getItem('loads') ?? 0) + 1;
+ sessionStorage.setItem('loads', String(loads));
-const debug: ClearBrowserCacheDebugFunc = (data) => {
- console.debug('[react-clear-browser-cache]', data);
-};
+ const debug: ClearBrowserCacheDebugFunc = (data) => {
+ console.debug('[react-clear-browser-cache]', data);
+ };
-createRoot(document.getElementById('root')!).render(
-
-
- Checking for updates…
}
- auto={params.get('auto') === '1'}
- duration={Number(params.get('duration')) || undefined}
- filename={`${import.meta.env.BASE_URL}meta.json`}
- debug={debug}
- >
-
-
-
-
-);
+ createRoot(document.getElementById('root')!).render(
+
+
+ Checking for updates…}
+ onUpdate={params.get('auto') === '1' ? 'reload' : undefined}
+ duration={Number(params.get('duration')) || undefined}
+ filename={`${import.meta.env.BASE_URL}meta.json`}
+ debug={debug}
+ prompt={params.get('prompt') === '1' ? UpdateDialog : undefined}
+ promptOnChunkError={params.get('promptOnChunkError') === '1'}
+ >
+
+
+
+
+ );
+}
diff --git a/example/src/vite-env.d.ts b/example/src/vite-env.d.ts
new file mode 100644
index 0000000..463cd71
--- /dev/null
+++ b/example/src/vite-env.d.ts
@@ -0,0 +1,5 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_DEMO?: string;
+}
diff --git a/package.json b/package.json
index 0a33ea7..5c69137 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,10 @@
{
"name": "react-clear-browser-cache",
- "version": "0.3.0",
+ "version": "1.0.0",
"author": "arenuzzz",
"description": "Library for clearing browser cache after react app updates",
"license": "MIT",
- "repository": "arenuzzz/react-clear-browser-cache",
+ "repository": "devmaxxx/react-clear-browser-cache",
"type": "commonjs",
"sideEffects": false,
"main": "./dist/index.cjs",
@@ -48,13 +48,15 @@
"typecheck": "tsc --noEmit && tsc --noEmit -p example && tsc --noEmit -p e2e",
"lint": "eslint . && prettier --check . && pnpm run lint:html",
"lint:html": "html-validate example/index.html",
- "test": "pnpm run lint && pnpm run build && pnpm run typecheck && pnpm run test:unit && pnpm run test:e2e",
+ "test": "pnpm run lint && pnpm run build && pnpm run typecheck && pnpm run test:unit && playwright test",
"test:unit": "vitest run",
"test:watch": "vitest",
- "test:e2e": "playwright test",
+ "test:e2e": "pnpm run build && playwright test",
"example:dev": "pnpm --filter react-clear-browser-cache-example dev",
"example:build": "pnpm --filter react-clear-browser-cache-example build",
- "deploy": "BASE_PATH=./ pnpm run example:build && gh-pages -d example/dist"
+ "demo:dev": "VITE_DEMO=1 pnpm run example:dev",
+ "demo:build": "VITE_DEMO=1 BASE_PATH=/react-clear-browser-cache/ pnpm --filter react-clear-browser-cache-example exec vite build --outDir dist-demo",
+ "demo:preview": "BASE_PATH=/react-clear-browser-cache/ pnpm --filter react-clear-browser-cache-example preview --outDir dist-demo"
},
"peerDependencies": {
"react": ">=18.0.0"
@@ -71,7 +73,6 @@
"eslint": "^10.13.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
- "gh-pages": "^6.3.0",
"globals": "^17.13.0",
"html-validate": "^11.16.2",
"jsdom": "^30.1.3",
diff --git a/playwright.config.mts b/playwright.config.mts
index b19969c..1ee43e0 100644
--- a/playwright.config.mts
+++ b/playwright.config.mts
@@ -1,6 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
const port = 4173;
+const demoPort = 4174;
+const demoBase = '/react-clear-browser-cache/';
export default defineConfig({
testDir: 'e2e',
@@ -13,15 +15,40 @@ export default defineConfig({
trace: 'retain-on-failure'
},
projects: [
- { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
- { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
- { name: 'webkit', use: { ...devices['Desktop Safari'] } }
+ ...(['Desktop Chrome', 'Desktop Firefox', 'Desktop Safari'] as const).map(
+ (device) => ({
+ name: devices[device].defaultBrowserType,
+ testIgnore: /demo\.spec/,
+ use: { ...devices[device] }
+ })
+ ),
+ {
+ name: 'demo',
+ testMatch: /demo\.spec/,
+ use: {
+ ...devices['Desktop Chrome'],
+ baseURL: `http://localhost:${demoPort}${demoBase}`
+ }
+ }
],
- webServer: {
- // The built index.html is checked too: the build rewrites it.
- command: `pnpm build && pnpm example:build && html-validate example/dist/index.html && pnpm --filter react-clear-browser-cache-example preview --port ${port} --strictPort`,
- url: `http://localhost:${port}`,
- reuseExistingServer: !process.env.CI,
- timeout: 120_000
- }
+ webServer: [
+ {
+ // Both servers need the library built first, which `test:e2e` does; two
+ // tsdown runs here would race on dist. The built index.html is checked
+ // too: the build rewrites it.
+ command: `pnpm example:build && html-validate example/dist/index.html && pnpm --filter react-clear-browser-cache-example preview --port ${port} --strictPort`,
+ url: `http://localhost:${port}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000
+ },
+ {
+ // Served under the same sub-path as GitHub Pages, so broken asset URLs
+ // fail here first. `demo:build` skips the meta.json prebuild step (the
+ // demo's fetch shim answers it), so it cannot race the build above.
+ command: `pnpm demo:build && pnpm demo:preview --port ${demoPort} --strictPort`,
+ url: `http://localhost:${demoPort}${demoBase}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000
+ }
+ ]
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6144c89..94c7031 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,9 +41,6 @@ importers:
eslint-plugin-react-hooks:
specifier: ^7.1.1
version: 7.1.1(eslint@10.13.0)
- gh-pages:
- specifier: ^6.3.0
- version: 6.3.0
globals:
specifier: ^17.13.0
version: 17.13.0
@@ -330,18 +327,6 @@ packages:
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
- '@nodelib/fs.scandir@2.1.5':
- resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
- engines: {node: '>= 8'}
-
- '@nodelib/fs.stat@2.0.5':
- resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
- engines: {node: '>= 8'}
-
- '@nodelib/fs.walk@1.2.8':
- resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
- engines: {node: '>= 8'}
-
'@oxc-project/types@0.154.0':
resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==}
@@ -697,17 +682,10 @@ packages:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
- array-union@2.1.0:
- resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
- engines: {node: '>=8'}
-
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
- async@3.2.6:
- resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
-
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -724,10 +702,6 @@ packages:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
- braces@3.0.3:
- resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
- engines: {node: '>=8'}
-
browserslist@4.29.3:
resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -747,13 +721,6 @@ packages:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
engines: {node: '>=18'}
- commander@13.1.0:
- resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
- engines: {node: '>=18'}
-
- commondir@1.0.1:
- resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
-
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -801,10 +768,6 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
- dir-glob@3.0.1:
- resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
- engines: {node: '>=8'}
-
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -823,9 +786,6 @@ packages:
electron-to-chromium@1.5.458:
resolution: {integrity: sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==}
- email-addresses@5.0.0:
- resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==}
-
empathic@2.1.0:
resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==}
engines: {node: '>=14'}
@@ -841,10 +801,6 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
- escape-string-regexp@1.0.5:
- resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
- engines: {node: '>=0.8.0'}
-
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -913,10 +869,6 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
- fast-glob@3.3.3:
- resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
- engines: {node: '>=8.6.0'}
-
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -926,9 +878,6 @@ packages:
fast-uri@3.1.8:
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
- fastq@1.20.3:
- resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
-
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -941,26 +890,6 @@ packages:
file-entry-cache@11.1.5:
resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==}
- filename-reserved-regex@2.0.0:
- resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
- engines: {node: '>=4'}
-
- filenamify@4.3.0:
- resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==}
- engines: {node: '>=8'}
-
- fill-range@7.1.1:
- resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
- engines: {node: '>=8'}
-
- find-cache-dir@3.3.2:
- resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
- engines: {node: '>=8'}
-
- find-up@4.1.0:
- resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
- engines: {node: '>=8'}
-
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -971,10 +900,6 @@ packages:
flatted@3.4.4:
resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
- fs-extra@11.4.1:
- resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==}
- engines: {node: '>=14.14'}
-
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -988,15 +913,6 @@ packages:
resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==}
engines: {node: '>=20.20.0'}
- gh-pages@6.3.0:
- resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==}
- engines: {node: '>=10'}
- hasBin: true
-
- glob-parent@5.1.2:
- resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
- engines: {node: '>= 6'}
-
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
@@ -1005,13 +921,6 @@ packages:
resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==}
engines: {node: '>=18'}
- globby@11.1.0:
- resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
- engines: {node: '>=10'}
-
- graceful-fs@4.2.11:
- resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
-
hashery@1.5.1:
resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
engines: {node: '>=20'}
@@ -1085,10 +994,6 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
- is-number@7.0.0:
- resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
- engines: {node: '>=0.12.0'}
-
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -1126,9 +1031,6 @@ packages:
engines: {node: '>=6'}
hasBin: true
- jsonfile@6.2.1:
- resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
-
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
@@ -1214,10 +1116,6 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
- locate-path@5.0.0:
- resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
- engines: {node: '>=8'}
-
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1236,21 +1134,9 @@ packages:
magic-string@1.4.3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
- make-dir@3.1.0:
- resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
- engines: {node: '>=8'}
-
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
- merge2@1.4.1:
- resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
- engines: {node: '>= 8'}
-
- micromatch@4.0.8:
- resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
- engines: {node: '>=8.6'}
-
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
@@ -1286,26 +1172,14 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
- p-limit@2.3.0:
- resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
- engines: {node: '>=6'}
-
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
- p-locate@4.1.0:
- resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
- engines: {node: '>=8'}
-
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
- p-try@2.2.0:
- resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
- engines: {node: '>=6'}
-
package-manager-detector@1.9.0:
resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
@@ -1320,25 +1194,13 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
- path-type@4.0.0:
- resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
- engines: {node: '>=8'}
-
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.2:
- resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
- engines: {node: '>=8.6'}
-
picomatch@4.0.7:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
- pkg-dir@4.2.0:
- resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
- engines: {node: '>=8'}
-
playwright-core@1.64.1:
resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==}
engines: {node: '>=20'}
@@ -1386,9 +1248,6 @@ packages:
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
- queue-microtask@1.2.3:
- resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
-
react-dom@19.3.1:
resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
peerDependencies:
@@ -1412,10 +1271,6 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
- reusify@1.1.0:
- resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
- engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
-
rolldown-plugin-dts@0.28.8:
resolution: {integrity: sha512-FVhfqxorCWcc20BVT5eVRfRjzLLl6mgzjcMR4gSBf0K4oQMXT0qoHhhzfXy0L7y83fQy7Ug5ZDNk90xM3/LiCQ==}
engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0}
@@ -1443,9 +1298,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- run-parallel@1.2.0:
- resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
-
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
@@ -1477,10 +1329,6 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
- slash@3.0.0:
- resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
- engines: {node: '>=8'}
-
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
@@ -1492,10 +1340,6 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
- strip-outer@1.0.1:
- resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
- engines: {node: '>=0.10.0'}
-
tinybench@6.2.2:
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
engines: {node: '>=20.0.0'}
@@ -1515,10 +1359,6 @@ packages:
resolution: {integrity: sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==}
hasBin: true
- to-regex-range@5.0.1:
- resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
- engines: {node: '>=8.0'}
-
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
@@ -1531,10 +1371,6 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
- trim-repeated@1.0.0:
- resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==}
- engines: {node: '>=0.10.0'}
-
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -1601,10 +1437,6 @@ packages:
resolution: {integrity: sha512-XtuDWVdqTLr3NAxm2ly9/hU6kUsJOgGh1RASzMBbsw6aWCOaQzHdW+QQDfdERjfJ2V5vL5pRPa4RetO9UfivDg==}
engines: {node: '>=22.19.0'}
- universalify@2.0.1:
- resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
- engines: {node: '>= 10.0.0'}
-
unrun@0.3.1:
resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==}
engines: {node: ^22.13.0 || >=24.0.0}
@@ -2023,18 +1855,6 @@ snapshots:
'@keyv/serialize@1.1.1': {}
- '@nodelib/fs.scandir@2.1.5':
- dependencies:
- '@nodelib/fs.stat': 2.0.5
- run-parallel: 1.2.0
-
- '@nodelib/fs.stat@2.0.5': {}
-
- '@nodelib/fs.walk@1.2.8':
- dependencies:
- '@nodelib/fs.scandir': 2.1.5
- fastq: 1.20.3
-
'@oxc-project/types@0.154.0': {}
'@playwright/test@1.64.1':
@@ -2335,12 +2155,8 @@ snapshots:
aria-query@5.3.2: {}
- array-union@2.1.0: {}
-
assertion-error@2.0.1: {}
- async@3.2.6: {}
-
balanced-match@4.0.4: {}
baseline-browser-mapping@2.11.29: {}
@@ -2353,10 +2169,6 @@ snapshots:
dependencies:
balanced-match: 4.0.4
- braces@3.0.3:
- dependencies:
- fill-range: 7.1.1
-
browserslist@4.29.3:
dependencies:
baseline-browser-mapping: 2.11.29
@@ -2379,10 +2191,6 @@ snapshots:
chai@6.3.0: {}
- commander@13.1.0: {}
-
- commondir@1.0.1: {}
-
convert-source-map@2.0.0: {}
cross-spawn@7.0.6:
@@ -2421,10 +2229,6 @@ snapshots:
detect-libc@2.1.2: {}
- dir-glob@3.0.1:
- dependencies:
- path-type: 4.0.0
-
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
@@ -2433,8 +2237,6 @@ snapshots:
electron-to-chromium@1.5.458: {}
- email-addresses@5.0.0: {}
-
empathic@2.1.0: {}
entities@8.1.0: {}
@@ -2443,8 +2245,6 @@ snapshots:
escalade@3.2.0: {}
- escape-string-regexp@1.0.5: {}
-
escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@10.13.0):
@@ -2534,24 +2334,12 @@ snapshots:
fast-deep-equal@3.1.3: {}
- fast-glob@3.3.3:
- dependencies:
- '@nodelib/fs.stat': 2.0.5
- '@nodelib/fs.walk': 1.2.8
- glob-parent: 5.1.2
- merge2: 1.4.1
- micromatch: 4.0.8
-
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fast-uri@3.1.8: {}
- fastq@1.20.3:
- dependencies:
- reusify: 1.1.0
-
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
@@ -2560,29 +2348,6 @@ snapshots:
dependencies:
flat-cache: 6.1.23
- filename-reserved-regex@2.0.0: {}
-
- filenamify@4.3.0:
- dependencies:
- filename-reserved-regex: 2.0.0
- strip-outer: 1.0.1
- trim-repeated: 1.0.0
-
- fill-range@7.1.1:
- dependencies:
- to-regex-range: 5.0.1
-
- find-cache-dir@3.3.2:
- dependencies:
- commondir: 1.0.1
- make-dir: 3.1.0
- pkg-dir: 4.2.0
-
- find-up@4.1.0:
- dependencies:
- locate-path: 5.0.0
- path-exists: 4.0.0
-
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -2596,12 +2361,6 @@ snapshots:
flatted@3.4.4: {}
- fs-extra@11.4.1:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.1
- universalify: 2.0.1
-
fsevents@2.3.3:
optional: true
@@ -2611,37 +2370,12 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
- gh-pages@6.3.0:
- dependencies:
- async: 3.2.6
- commander: 13.1.0
- email-addresses: 5.0.0
- filenamify: 4.3.0
- find-cache-dir: 3.3.2
- fs-extra: 11.4.1
- globby: 11.1.0
-
- glob-parent@5.1.2:
- dependencies:
- is-glob: 4.0.3
-
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
globals@17.13.0: {}
- globby@11.1.0:
- dependencies:
- array-union: 2.1.0
- dir-glob: 3.0.1
- fast-glob: 3.3.3
- ignore: 5.3.2
- merge2: 1.4.1
- slash: 3.0.0
-
- graceful-fs@4.2.11: {}
-
hashery@1.5.1:
dependencies:
hookified: 1.15.1
@@ -2691,8 +2425,6 @@ snapshots:
dependencies:
is-extglob: 2.1.1
- is-number@7.0.0: {}
-
is-potential-custom-element-name@1.0.1: {}
isexe@2.0.0: {}
@@ -2734,12 +2466,6 @@ snapshots:
json5@2.2.3: {}
- jsonfile@6.2.1:
- dependencies:
- universalify: 2.0.1
- optionalDependencies:
- graceful-fs: 4.2.11
-
keyv@5.6.0:
dependencies:
'@keyv/serialize': 1.1.1
@@ -2802,10 +2528,6 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
- locate-path@5.0.0:
- dependencies:
- p-locate: 4.1.0
-
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -2822,19 +2544,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
- make-dir@3.1.0:
- dependencies:
- semver: 6.3.1
-
mdn-data@2.27.1: {}
- merge2@1.4.1: {}
-
- micromatch@4.0.8:
- dependencies:
- braces: 3.0.3
- picomatch: 2.3.2
-
min-indent@1.0.1: {}
minimatch@10.2.6:
@@ -2862,24 +2573,14 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
- p-limit@2.3.0:
- dependencies:
- p-try: 2.2.0
-
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
- p-locate@4.1.0:
- dependencies:
- p-limit: 2.3.0
-
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
- p-try@2.2.0: {}
-
package-manager-detector@1.9.0: {}
parse5@8.0.1:
@@ -2890,18 +2591,10 @@ snapshots:
path-key@3.1.1: {}
- path-type@4.0.0: {}
-
picocolors@1.1.1: {}
- picomatch@2.3.2: {}
-
picomatch@4.0.7: {}
- pkg-dir@4.2.0:
- dependencies:
- find-up: 4.1.0
-
playwright-core@1.64.1: {}
playwright@1.64.1:
@@ -2944,8 +2637,6 @@ snapshots:
quansync@1.0.0: {}
- queue-microtask@1.2.3: {}
-
react-dom@19.3.1(react@19.3.1):
dependencies:
react: 19.3.1
@@ -2964,8 +2655,6 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
- reusify@1.1.0: {}
-
rolldown-plugin-dts@0.28.8(rolldown@1.2.14)(typescript@6.0.3):
dependencies:
dts-resolver: 3.0.0
@@ -3001,10 +2690,6 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.14
'@rolldown/binding-win32-x64-msvc': 1.2.14
- run-parallel@1.2.0:
- dependencies:
- queue-microtask: 1.2.3
-
sade@1.8.1:
dependencies:
mri: 1.2.0
@@ -3027,8 +2712,6 @@ snapshots:
sisteransi@1.0.5: {}
- slash@3.0.0: {}
-
source-map-js@1.2.2: {}
std-env@4.3.0: {}
@@ -3037,10 +2720,6 @@ snapshots:
dependencies:
min-indent: 1.0.1
- strip-outer@1.0.1:
- dependencies:
- escape-string-regexp: 1.0.5
-
tinybench@6.2.2: {}
tinyexec@1.3.1: {}
@@ -3056,10 +2735,6 @@ snapshots:
dependencies:
tldts-core: 7.4.18
- to-regex-range@5.0.1:
- dependencies:
- is-number: 7.0.0
-
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.18
@@ -3070,10 +2745,6 @@ snapshots:
tree-kill@1.2.2: {}
- trim-repeated@1.0.0:
- dependencies:
- escape-string-regexp: 1.0.5
-
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
@@ -3131,8 +2802,6 @@ snapshots:
undici@8.12.2: {}
- universalify@2.0.1: {}
-
unrun@0.3.1:
dependencies:
rolldown: 1.2.14
diff --git a/src/ClearBrowserCacheBoundary.tsx b/src/ClearBrowserCacheBoundary.tsx
index c8582ff..5fb537f 100644
--- a/src/ClearBrowserCacheBoundary.tsx
+++ b/src/ClearBrowserCacheBoundary.tsx
@@ -5,12 +5,19 @@ import {
defaultErrorCheckers,
isCacheError
} from './errorCheckers';
+import type { UpdatePromptProps } from './updatePrompt';
export type ClearBrowserCacheStorage = {
get: (key: string) => string | null;
set: (key: string, value: string) => void;
};
+/** @deprecated Unused since 1.0; kept so 0.x imports still compile. */
+export type ClearBrowserCacheAppVersionStorage = {
+ get: () => string;
+ set: (version: string) => void;
+};
+
export type ClearBrowserCacheDebugFunc = (params: {
state: ClearBrowserCacheBoundaryState;
error?: unknown;
@@ -24,7 +31,17 @@ export type ClearBrowserCacheBoundaryState = {
disabled: boolean;
};
+/**
+ * What happens when a check finds a newer build while the app is on screen:
+ * `prompt` shows `prompt`, `reload` reloads at once, `flag` only sets
+ * `isLatestVersion` to false and leaves the rest to you.
+ */
+export type ClearBrowserCacheUpdateMode = 'prompt' | 'reload' | 'flag';
+
export type ClearBrowserCacheBoundaryProps = React.PropsWithChildren<{
+ /** Defaults to `prompt` when a `prompt` is given, `flag` otherwise. */
+ onUpdate?: ClearBrowserCacheUpdateMode;
+ /** @deprecated Use `onUpdate="reload"`. */
auto?: boolean;
storageKey?: string;
filename?: string;
@@ -33,10 +50,37 @@ export type ClearBrowserCacheBoundaryProps = React.PropsWithChildren<{
fallback?: React.ReactNode;
debug?: ClearBrowserCacheDebugFunc;
errorCheckers?: ClearBrowserCacheErrorChecker[];
+ /**
+ * Asks the user to reload. An element, or a component that receives
+ * `UpdatePromptProps`.
+ */
+ prompt?: React.ReactNode | React.ComponentType;
+ /**
+ * When a chunk of the old build fails to load, show `prompt` over the
+ * fallback instead of reloading straight away.
+ */
+ promptOnChunkError?: boolean;
+ /**
+ * Reload into a newer build as soon as the tab goes to the background,
+ * where the user cannot see it happen.
+ */
+ reloadWhenHidden?: boolean;
+ /**
+ * Asked before every reload the user did not start (`onUpdate="reload"`,
+ * `reloadWhenHidden`, and the check on mount with `blocking={false}`);
+ * return false to keep the page, e.g. while a form has unsaved changes.
+ */
+ canReload?: () => boolean;
+ /**
+ * Show `fallback` until the first version check answers. Off renders the
+ * app at once and checks in the background.
+ */
+ blocking?: boolean;
}>;
export type ClearBrowserCacheContextValue = ClearBrowserCacheBoundaryState & {
clearCacheAndReload: () => Promise;
+ dismissUpdate: () => void;
};
type ClearBrowserCacheProps = {
@@ -45,8 +89,15 @@ type ClearBrowserCacheProps = {
type State = ClearBrowserCacheBoundaryState & {
caughtError: { error: unknown; rethrow: boolean } | null;
+ dismissedVersion: string | null;
};
+type CheckResult = 'reloading' | 'outdated' | 'current' | 'failed';
+
+// `guarded` reloads only if `canReload` agrees; `unattended` follows
+// `onUpdate` and `reloadWhenHidden`.
+type ReloadPolicy = 'always' | 'never' | 'guarded' | 'unattended';
+
const unknownVersion = 'latest';
const localStorageAdapter: ClearBrowserCacheStorage = {
@@ -70,6 +121,25 @@ function isUrlLike(filename: string) {
return /^(\/|\.{1,2}\/|[a-z][a-z\d+.-]*:)/i.test(filename);
}
+const componentTypes = new Set([
+ Symbol.for('react.memo'),
+ Symbol.for('react.forward_ref'),
+ Symbol.for('react.lazy')
+]);
+
+// A ReactNode can be an object too (an element, a portal), so a component is
+// told apart by being a function or one of React's component wrappers.
+function isComponentType(
+ value: React.ReactNode | React.ComponentType
+): value is React.ComponentType
{
+ return (
+ typeof value === 'function' ||
+ (typeof value === 'object' &&
+ value !== null &&
+ componentTypes.has((value as { $$typeof?: symbol }).$$typeof!))
+ );
+}
+
function readStoredVersion(storage: ClearBrowserCacheStorage, key: string) {
try {
return { version: storage.get(key), disabled: false };
@@ -84,27 +154,23 @@ export class ClearBrowserCacheBoundary extends React.Component<
> {
// Version whose assets the browser currently holds; null before the first
// successful check on this device.
- appVersion: string | null;
+ appVersion: string | null = null;
checkInterval: ReturnType | null = null;
+ lastCheckAt = 0;
+ storageRead = false;
+ storageEnabled = true;
unmounted = false;
- constructor(props: ClearBrowserCacheBoundaryProps) {
- super(props);
-
- const { version, disabled } = readStoredVersion(
- this.storage,
- this.storageKey
- );
-
- this.appVersion = version;
- this.state = {
- loading: !disabled,
- latestVersion: version ?? unknownVersion,
- disabled,
- isLatestVersion: true,
- caughtError: null
- };
- }
+ // Storage is read after mount, not here: the server has none, and the first
+ // client render has to match the server's HTML to hydrate.
+ state: State = {
+ loading: true,
+ latestVersion: unknownVersion,
+ disabled: false,
+ isLatestVersion: true,
+ caughtError: null,
+ dismissedVersion: null
+ };
static getDerivedStateFromError(error: unknown): Partial {
return { caughtError: { error, rethrow: false } };
@@ -130,16 +196,45 @@ export class ClearBrowserCacheBoundary extends React.Component<
return url.href;
}
+ // Returns whether storage works. Read once: StrictMode remounts the same
+ // instance, and a second read would undo the first check's state. The answer
+ // is kept in a field because `setState` has not landed yet when
+ // `componentDidCatch` runs in the same commit as the first mount.
+ readStorage() {
+ if (!this.storageRead) {
+ this.storageRead = true;
+
+ const { version, disabled } = readStoredVersion(
+ this.storage,
+ this.storageKey
+ );
+
+ this.appVersion = version;
+ this.storageEnabled = !disabled;
+ this.setState({
+ loading: !disabled,
+ latestVersion: version ?? unknownVersion,
+ disabled
+ });
+ }
+
+ return this.storageEnabled;
+ }
+
componentDidMount() {
// StrictMode unmounts and remounts the same instance in development.
this.unmounted = false;
- window.addEventListener('focus', this.startVersionCheck);
+ const enabled = this.readStorage();
+ window.addEventListener('focus', this.handleFocus);
window.addEventListener('blur', this.stopVersionCheck);
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
- // Nothing has been shown yet, so a stale build is reloaded on mount even
- // without `auto`.
- if (!this.state.disabled) {
- this.checkVersion({ auto: true }).then(() => {
+ // A blocking mount has shown nothing yet, so a stale build is reloaded
+ // whatever `onUpdate` says. A non-blocking one is already interactive.
+ if (enabled) {
+ const reload = (this.props.blocking ?? true) ? 'always' : 'guarded';
+
+ this.checkVersion({ reload }).then(() => {
if (!this.unmounted && document.hasFocus()) {
this.startVersionCheck();
}
@@ -150,11 +245,7 @@ export class ClearBrowserCacheBoundary extends React.Component<
}
componentDidUpdate(prevProps: ClearBrowserCacheBoundaryProps) {
- const { duration, auto } = this.props;
- const pollingChanged =
- duration !== prevProps.duration || auto !== prevProps.auto;
-
- if (pollingChanged && document.hasFocus()) {
+ if (this.props.duration !== prevProps.duration && document.hasFocus()) {
this.startVersionCheck();
}
@@ -165,8 +256,12 @@ export class ClearBrowserCacheBoundary extends React.Component<
this.unmounted = true;
this.stopVersionCheck();
- window.removeEventListener('focus', this.startVersionCheck);
+ window.removeEventListener('focus', this.handleFocus);
window.removeEventListener('blur', this.stopVersionCheck);
+ document.removeEventListener(
+ 'visibilitychange',
+ this.handleVisibilityChange
+ );
}
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) {
@@ -174,15 +269,21 @@ export class ClearBrowserCacheBoundary extends React.Component<
this.props.errorCheckers ?? []
);
- if (this.state.disabled || !isCacheError(error, errorCheckers)) {
+ // A child of a non-blocking boundary can fail before the boundary mounts.
+ if (!this.readStorage() || !isCacheError(error, errorCheckers)) {
this.rethrow(error);
return;
}
- this.checkVersion({ auto: true }).then((reloading) => {
+ const { promptOnChunkError, prompt } = this.props;
+ const ask = Boolean(promptOnChunkError) && prompt != null;
+
+ this.checkVersion({ reload: ask ? 'never' : 'always' }).then((result) => {
this.debug(error, errorInfo);
- if (!reloading) {
+ // 'outdated' only comes back with the prompt on, which now shows over
+ // the fallback and lets the user decide.
+ if (result !== 'reloading' && result !== 'outdated') {
this.rethrow(error);
}
});
@@ -194,8 +295,22 @@ export class ClearBrowserCacheBoundary extends React.Component<
}
}
+ dismissUpdate = () => {
+ const { caughtError, latestVersion } = this.state;
+
+ if (caughtError) {
+ this.rethrow(caughtError.error);
+ } else {
+ this.setState({ dismissedVersion: latestVersion });
+ }
+ };
+
debug = (error?: unknown, errorInfo?: React.ErrorInfo) => {
- const { caughtError: _caughtError, ...state } = this.state;
+ const {
+ caughtError: _caughtError,
+ dismissedVersion: _dismissedVersion,
+ ...state
+ } = this.state;
// A throwing debug callback must not break the version check that called it.
try {
@@ -205,14 +320,63 @@ export class ClearBrowserCacheBoundary extends React.Component<
}
};
+ // Polling stops while the window is in the background, so a tab left alone
+ // for hours would otherwise wait a whole `duration` after coming back.
+ handleFocus = () => {
+ const { duration } = this.props;
+
+ if (
+ !this.state.disabled &&
+ duration &&
+ Date.now() - this.lastCheckAt >= duration
+ ) {
+ this.checkVersion();
+ }
+
+ this.startVersionCheck();
+ };
+
+ handleVisibilityChange = () => {
+ const { loading, isLatestVersion, caughtError, disabled } = this.state;
+
+ if (
+ document.visibilityState !== 'hidden' ||
+ !this.props.reloadWhenHidden ||
+ disabled ||
+ loading ||
+ caughtError
+ ) {
+ return;
+ }
+
+ if (!isLatestVersion) {
+ if (this.reloadAllowed()) {
+ this.clearCacheAndReload();
+ }
+ } else if (this.props.duration) {
+ // Polling stopped on blur, so a deploy since the last poll is not known
+ // yet; checking now reloads into it while the tab is still hidden.
+ this.checkVersion();
+ }
+ };
+
+ reloadAllowed() {
+ try {
+ return this.props.canReload?.() ?? true;
+ } catch (error) {
+ this.debug(error);
+ return false;
+ }
+ }
+
startVersionCheck = () => {
- const { duration, auto = false } = this.props;
+ const { duration } = this.props;
this.stopVersionCheck();
if (!this.state.disabled && duration) {
this.checkInterval = setInterval(() => {
- this.checkVersion({ auto });
+ this.checkVersion();
}, duration);
}
};
@@ -272,8 +436,46 @@ export class ClearBrowserCacheBoundary extends React.Component<
}
}
- /** Resolves to true when a page reload was started. */
- checkVersion = async ({ auto = this.props.auto ?? false } = {}) => {
+ get updateMode(): ClearBrowserCacheUpdateMode {
+ const { onUpdate, auto, prompt } = this.props;
+
+ if (onUpdate) {
+ return onUpdate;
+ }
+
+ if (auto) {
+ return 'reload';
+ }
+
+ return prompt != null ? 'prompt' : 'flag';
+ }
+
+ // The guard is asked only once a newer build is known, so it sees the page
+ // as it is at reload time and is not called on every poll.
+ shouldReload(policy: ReloadPolicy) {
+ switch (policy) {
+ case 'always':
+ return true;
+ case 'never':
+ return false;
+ case 'guarded':
+ return this.reloadAllowed();
+ case 'unattended': {
+ const wanted =
+ this.updateMode === 'reload' ||
+ (Boolean(this.props.reloadWhenHidden) &&
+ document.visibilityState === 'hidden');
+
+ return wanted && this.reloadAllowed();
+ }
+ }
+ }
+
+ checkVersion = async ({
+ reload = 'unattended'
+ }: { reload?: ReloadPolicy } = {}): Promise => {
+ this.lastCheckAt = Date.now();
+
try {
const latestVersion = await this.fetchLatestVersion();
@@ -284,18 +486,20 @@ export class ClearBrowserCacheBoundary extends React.Component<
const isLatestVersion = latestVersion === this.appVersion;
- if (!isLatestVersion && auto) {
+ if (!isLatestVersion && this.shouldReload(reload)) {
await this.clearCacheAndReload(latestVersion);
- return true;
+ return 'reloading';
}
this.updateState({ loading: false, latestVersion, isLatestVersion });
+
+ return isLatestVersion ? 'current' : 'outdated';
} catch (error) {
this.debug(error);
this.updateState({ loading: false });
- }
- return false;
+ return 'failed';
+ }
};
updateState(patch: Partial) {
@@ -314,8 +518,44 @@ export class ClearBrowserCacheBoundary extends React.Component<
clearCacheAndReloadFromContext = () => this.clearCacheAndReload();
+ renderUpdatePrompt() {
+ const { prompt } = this.props;
+ const {
+ caughtError,
+ loading,
+ isLatestVersion,
+ latestVersion,
+ dismissedVersion
+ } = this.state;
+ const reason = caughtError ? 'error' : 'poll';
+ const dismissed = reason === 'poll' && latestVersion === dismissedVersion;
+
+ // `reload` mode lands here only when `canReload` held the reload back,
+ // so the user is asked instead.
+ const silent = reason === 'poll' && this.updateMode === 'flag';
+
+ if (prompt == null || silent || loading || isLatestVersion || dismissed) {
+ return null;
+ }
+
+ if (!isComponentType(prompt)) {
+ return prompt;
+ }
+
+ const Prompt = prompt;
+
+ return (
+
+ );
+ }
+
render() {
- const { children, fallback = null } = this.props;
+ const { children, fallback = null, blocking = true } = this.props;
const { caughtError, loading, isLatestVersion, latestVersion, disabled } =
this.state;
@@ -323,10 +563,6 @@ export class ClearBrowserCacheBoundary extends React.Component<
throw caughtError.error;
}
- if (caughtError || loading) {
- return fallback;
- }
-
return (
- {children}
+ {caughtError || (loading && blocking) ? fallback : children}
+ {this.renderUpdatePrompt()}
);
}
diff --git a/src/errorCheckers.ts b/src/errorCheckers.ts
index 0609254..446f65c 100644
--- a/src/errorCheckers.ts
+++ b/src/errorCheckers.ts
@@ -21,6 +21,9 @@ export const defaultErrorCheckers: ClearBrowserCacheErrorChecker[] = [
// webpack: numeric ids in v4, named ids in v5; mini-css-extract-plugin
// raises its CSS variant under the same name
createErrorChecker('ChunkLoadError', /Loading (?:CSS )?chunk \S+ failed/),
+ // Turbopack (Next.js): a plain Error in older releases, ChunkLoadError later
+ createErrorChecker('ChunkLoadError', /Failed to load chunk \S+/),
+ createErrorChecker('Error', /Failed to load chunk \S+/),
// older mini-css-extract-plugin releases used the generic name
createErrorChecker('Error', /Loading CSS chunk \S+ failed/),
// Vite's modulepreload helper
diff --git a/src/index.ts b/src/index.ts
index 688eebf..ad78252 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './ClearBrowserCacheBoundary';
export * from './errorCheckers';
export * from './lazyRetry';
+export * from './updatePrompt';
diff --git a/src/lazyRetry.ts b/src/lazyRetry.ts
index 55406e8..0f364f9 100644
--- a/src/lazyRetry.ts
+++ b/src/lazyRetry.ts
@@ -1,20 +1,21 @@
/**
- * Calls `fn` and, while it rejects, calls it again up to `retries` more times,
- * waiting `interval` ms between attempts. Rejects with the last error.
+ * Calls `fn` up to `attempts` times in all, waiting `interval` ms between
+ * attempts, until it resolves. Rejects with the last error.
*/
export function lazyRetry(
fn: () => Promise,
- retries = 2,
+ attempts = 2,
interval = 1000
): Promise {
return fn().catch((error: unknown) => {
- if (retries <= 0) {
+ // 0.x retried forever on 0; anything below 2 now means a single attempt.
+ if (attempts <= 1) {
throw error;
}
return new Promise((resolve, reject) => {
setTimeout(() => {
- lazyRetry(fn, retries - 1, interval).then(resolve, reject);
+ lazyRetry(fn, attempts - 1, interval).then(resolve, reject);
}, interval);
});
});
diff --git a/src/updatePrompt.ts b/src/updatePrompt.ts
new file mode 100644
index 0000000..8c7f0b3
--- /dev/null
+++ b/src/updatePrompt.ts
@@ -0,0 +1,18 @@
+export type UpdatePromptReason = 'poll' | 'error';
+
+export type UpdatePromptProps = {
+ latestVersion: string;
+ /**
+ * `poll`: a newer build was found while the app kept working.
+ * `error`: part of the app failed to load because of it, and the boundary
+ * shows its fallback until the user decides.
+ */
+ reason: UpdatePromptReason;
+ /** Clears Cache Storage and reloads the page. */
+ reload: () => Promise;
+ /**
+ * Hides the prompt until a newer build than `latestVersion` is found. For
+ * `reason: 'error'` the error goes on to the parent error boundary instead.
+ */
+ dismiss: () => void;
+};
diff --git a/test/ClearBrowserCacheBoundary.test.tsx b/test/ClearBrowserCacheBoundary.test.tsx
index 05f69dd..204183e 100644
--- a/test/ClearBrowserCacheBoundary.test.tsx
+++ b/test/ClearBrowserCacheBoundary.test.tsx
@@ -7,6 +7,14 @@ import {
waitFor
} from '@testing-library/react';
+import {
+ Catcher,
+ STORAGE_KEY,
+ Thrower,
+ chunkError,
+ metaResponse,
+ mockFetch
+} from './helpers';
import {
ClearBrowserCache,
ClearBrowserCacheBoundary,
@@ -15,23 +23,6 @@ import {
useClearBrowserCache
} from '../src';
-const STORAGE_KEY = 'APP_VERSION';
-
-function metaResponse(version: string) {
- return new Response(JSON.stringify({ version }), {
- headers: { 'content-type': 'application/json' }
- });
-}
-
-function mockFetch(...responses: (() => Response)[]) {
- const fetchMock = vi.fn(async (_url: string) => {
- const next = responses.length > 1 ? responses.shift()! : responses[0];
- return next();
- });
- vi.stubGlobal('fetch', fetchMock);
- return fetchMock;
-}
-
function VersionState() {
const { loading, isLatestVersion, latestVersion, disabled } =
useClearBrowserCache();
@@ -58,34 +49,6 @@ function renderBoundary(props: ClearBrowserCacheBoundaryProps = {}) {
);
}
-class Catcher extends React.Component<
- React.PropsWithChildren,
- { error: unknown }
-> {
- state = { error: null as unknown };
-
- static getDerivedStateFromError(error: unknown) {
- return { error };
- }
-
- render() {
- if (this.state.error) {
- return Caught: {String(this.state.error)}
;
- }
- return this.props.children;
- }
-}
-
-function Thrower({ error }: { error: Error }): React.ReactNode {
- throw error;
-}
-
-function chunkError() {
- const error = new Error('Loading chunk 3 failed.');
- error.name = 'ChunkLoadError';
- return error;
-}
-
describe('ClearBrowserCacheBoundary', () => {
beforeEach(() => {
// React logs every caught render error; the tests assert on the outcome.
diff --git a/test/errorCheckers.test.ts b/test/errorCheckers.test.ts
index e6ddfb7..63e38f1 100644
--- a/test/errorCheckers.test.ts
+++ b/test/errorCheckers.test.ts
@@ -13,6 +13,16 @@ function makeError(name: string, message: string) {
describe('defaultErrorCheckers', () => {
it.each([
['webpack 4 numeric chunk', 'ChunkLoadError', 'Loading chunk 3 failed.'],
+ [
+ 'Turbopack chunk',
+ 'ChunkLoadError',
+ 'Failed to load chunk /_next/static/chunks/app_reports_page.js from module 1234'
+ ],
+ [
+ 'older Turbopack chunk',
+ 'Error',
+ 'Failed to load chunk static/chunks/app_reports_page.js'
+ ],
[
'webpack 5 named chunk',
'ChunkLoadError',
diff --git a/test/helpers.tsx b/test/helpers.tsx
new file mode 100644
index 0000000..cbab198
--- /dev/null
+++ b/test/helpers.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+
+export const STORAGE_KEY = 'APP_VERSION';
+
+export function metaResponse(version: string) {
+ return new Response(JSON.stringify({ version }), {
+ headers: { 'content-type': 'application/json' }
+ });
+}
+
+export function mockFetch(...responses: (() => Response)[]) {
+ const fetchMock = vi.fn(async (_url: string) => {
+ const next = responses.length > 1 ? responses.shift()! : responses[0];
+ return next();
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+export class Catcher extends React.Component<
+ React.PropsWithChildren,
+ { error: unknown }
+> {
+ state = { error: null as unknown };
+
+ static getDerivedStateFromError(error: unknown) {
+ return { error };
+ }
+
+ render() {
+ if (this.state.error) {
+ return Caught: {String(this.state.error)}
;
+ }
+ return this.props.children;
+ }
+}
+
+export function Thrower({ error }: { error: Error }): React.ReactNode {
+ throw error;
+}
+
+export function chunkError() {
+ const error = new Error('Loading chunk 3 failed.');
+ error.name = 'ChunkLoadError';
+ return error;
+}
diff --git a/test/hydration.test.tsx b/test/hydration.test.tsx
new file mode 100644
index 0000000..ab8a250
--- /dev/null
+++ b/test/hydration.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { act } from '@testing-library/react';
+import { hydrateRoot } from 'react-dom/client';
+import { renderToString } from 'react-dom/server';
+
+import { STORAGE_KEY, metaResponse, mockFetch } from './helpers';
+import { ClearBrowserCacheBoundary, useClearBrowserCache } from '../src';
+
+function Version() {
+ const { latestVersion } = useClearBrowserCache();
+
+ return {latestVersion}
;
+}
+
+it('hydrates server HTML without a mismatch when a version is stored', async () => {
+ window.localStorage.setItem(STORAGE_KEY, 'v1');
+ mockFetch(() => metaResponse('v1'));
+ const app = (
+
+
+
+ );
+ const container = document.createElement('div');
+ container.innerHTML = renderToString(app);
+ document.body.append(container);
+ const onRecoverableError = vi.fn();
+ const consoleError = vi.spyOn(console, 'error');
+
+ const root = await act(async () =>
+ hydrateRoot(container, app, { onRecoverableError })
+ );
+
+ expect(onRecoverableError).not.toHaveBeenCalled();
+ expect(consoleError).not.toHaveBeenCalled();
+ expect(container).toHaveTextContent('v1');
+
+ act(() => root.unmount());
+ container.remove();
+});
diff --git a/test/lazyRetry.test.ts b/test/lazyRetry.test.ts
index 1dc3792..f04c10d 100644
--- a/test/lazyRetry.test.ts
+++ b/test/lazyRetry.test.ts
@@ -32,14 +32,14 @@ describe('lazyRetry', () => {
expect(fn).toHaveBeenCalledTimes(2);
});
- it('makes `retries` extra attempts, then rejects with the last error', async () => {
+ it('makes `attempts` calls in all, then rejects with the last error', async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error('1'))
.mockRejectedValueOnce(new Error('2'))
.mockRejectedValueOnce(new Error('3'));
- const result = lazyRetry(fn, 2, 100);
+ const result = lazyRetry(fn, 3, 100);
const assertion = expect(result).rejects.toThrow('3');
await vi.advanceTimersByTimeAsync(200);
@@ -47,10 +47,22 @@ describe('lazyRetry', () => {
expect(fn).toHaveBeenCalledTimes(3);
});
- it('rejects at once when no retries are allowed', async () => {
+ it('makes two attempts by default, as 0.x did', async () => {
const fn = vi.fn().mockRejectedValue(new Error('gone'));
- await expect(lazyRetry(fn, 0)).rejects.toThrow('gone');
+ const assertion = expect(lazyRetry(fn, undefined, 100)).rejects.toThrow(
+ 'gone'
+ );
+
+ await vi.advanceTimersByTimeAsync(100);
+ await assertion;
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+
+ it.each([0, 1])('makes a single attempt for %i', async (attempts) => {
+ const fn = vi.fn().mockRejectedValue(new Error('gone'));
+
+ await expect(lazyRetry(fn, attempts)).rejects.toThrow('gone');
expect(fn).toHaveBeenCalledTimes(1);
});
});
diff --git a/test/serverRender.test.tsx b/test/serverRender.test.tsx
new file mode 100644
index 0000000..5a402da
--- /dev/null
+++ b/test/serverRender.test.tsx
@@ -0,0 +1,31 @@
+// @vitest-environment node
+import React from 'react';
+import { renderToString } from 'react-dom/server';
+
+import { ClearBrowserCacheBoundary } from '../src';
+
+describe('server rendering', () => {
+ it('renders the fallback without touching browser globals', () => {
+ const html = renderToString(
+ Update
}
+ >
+ App
+
+ );
+
+ expect(html).toContain('Checking version');
+ expect(html).not.toContain('App');
+ });
+
+ it('renders the app when not blocking', () => {
+ const html = renderToString(
+
+ App
+
+ );
+
+ expect(html).toContain('App');
+ });
+});
diff --git a/test/setup.ts b/test/setup.ts
index c8f0031..b307eb7 100644
--- a/test/setup.ts
+++ b/test/setup.ts
@@ -1,7 +1,10 @@
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
-afterEach(() => {
- cleanup();
- window.localStorage.clear();
-});
+// Server-rendering tests run in the node environment, without a DOM.
+if (typeof window !== 'undefined') {
+ afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+ });
+}
diff --git a/test/smoothUpdates.test.tsx b/test/smoothUpdates.test.tsx
new file mode 100644
index 0000000..cf31de5
--- /dev/null
+++ b/test/smoothUpdates.test.tsx
@@ -0,0 +1,360 @@
+import React from 'react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor
+} from '@testing-library/react';
+
+import { STORAGE_KEY, metaResponse, mockFetch } from './helpers';
+import {
+ ClearBrowserCacheBoundary,
+ type ClearBrowserCacheBoundaryProps,
+ useClearBrowserCache
+} from '../src';
+
+function VersionState() {
+ const { loading, isLatestVersion } = useClearBrowserCache();
+
+ return (
+
+ {String(loading)}
+ {String(isLatestVersion)}
+
+ );
+}
+
+function renderBoundary(props: ClearBrowserCacheBoundaryProps = {}) {
+ return render(
+
+
+
+ );
+}
+
+function setVisibility(state: DocumentVisibilityState) {
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue(state);
+ fireEvent(document, new Event('visibilitychange'));
+}
+
+const wait = (ms: number) =>
+ act(() => new Promise((resolve) => setTimeout(resolve, ms)));
+
+async function waitUntilOutdated() {
+ await waitFor(() =>
+ expect(screen.getByTestId('isLatestVersion')).toHaveTextContent('false')
+ );
+}
+
+beforeEach(() => {
+ vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ window.localStorage.setItem(STORAGE_KEY, 'v1');
+});
+
+describe('checking when the user comes back', () => {
+ it('checks at once on focus when a whole `duration` has passed', async () => {
+ const fetchMock = mockFetch(() => metaResponse('v1'));
+ const now = vi.spyOn(Date, 'now');
+
+ renderBoundary({ duration: 60_000 });
+ await screen.findByTestId('isLatestVersion');
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ fireEvent.blur(window);
+ now.mockReturnValue(Date.now() + 60_000 * 60);
+ fireEvent.focus(window);
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
+ });
+
+ it('waits for the timer when the last check was recent', async () => {
+ const fetchMock = mockFetch(() => metaResponse('v1'));
+
+ renderBoundary({ duration: 60_000 });
+ await screen.findByTestId('isLatestVersion');
+
+ fireEvent.blur(window);
+ fireEvent.focus(window);
+ await wait(30);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not check on focus without `duration`', async () => {
+ const fetchMock = mockFetch(() => metaResponse('v1'));
+
+ renderBoundary();
+ await screen.findByTestId('isLatestVersion');
+
+ fireEvent.focus(window);
+ await wait(30);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('reloadWhenHidden', () => {
+ it('reloads into the new build when the tab is hidden', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10, reloadWhenHidden: true });
+ await waitUntilOutdated();
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+
+ setVisibility('hidden');
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+
+ it('reloads when a check finds a new build after the tab went hidden', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10, reloadWhenHidden: true });
+ await screen.findByTestId('isLatestVersion');
+ setVisibility('hidden');
+ fireEvent.focus(window);
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+
+ it('checks once when the tab goes hidden, and reloads into a newer build', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 60_000, reloadWhenHidden: true });
+ await screen.findByTestId('isLatestVersion');
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+
+ setVisibility('hidden');
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+
+ it('leaves a current build alone', async () => {
+ mockFetch(() => metaResponse('v1'));
+ const caches = { keys: vi.fn(async () => []), delete: vi.fn() };
+ vi.stubGlobal('caches', caches);
+
+ renderBoundary({ duration: 10, reloadWhenHidden: true });
+ await screen.findByTestId('isLatestVersion');
+
+ setVisibility('hidden');
+ await wait(30);
+
+ expect(caches.keys).not.toHaveBeenCalled();
+ });
+
+ it('is off by default', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10 });
+ await waitUntilOutdated();
+
+ setVisibility('hidden');
+ await wait(30);
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ });
+});
+
+describe('canReload', () => {
+ it('keeps the page when `auto` finds a new build', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({
+ duration: 10,
+ onUpdate: 'reload',
+ canReload: () => false
+ });
+ await waitUntilOutdated();
+ await wait(30);
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ });
+
+ it('keeps the page when the tab is hidden', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({
+ duration: 10,
+ reloadWhenHidden: true,
+ canReload: () => false
+ });
+ await waitUntilOutdated();
+
+ setVisibility('hidden');
+ await wait(30);
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ });
+
+ it('counts a throwing guard as a no', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+ const debug = vi.fn();
+
+ renderBoundary({
+ duration: 10,
+ onUpdate: 'reload',
+ debug,
+ canReload: () => {
+ throw new Error('guard failed');
+ }
+ });
+ await waitUntilOutdated();
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ expect(debug).toHaveBeenCalledWith(
+ expect.objectContaining({ error: new Error('guard failed') })
+ );
+ });
+
+ it('does not ask the guard while the build is current', async () => {
+ mockFetch(() => metaResponse('v1'));
+ const canReload = vi.fn(() => true);
+
+ renderBoundary({ duration: 10, onUpdate: 'reload', canReload });
+ await screen.findByTestId('isLatestVersion');
+ await wait(40);
+
+ expect(canReload).not.toHaveBeenCalled();
+ });
+
+ it('reloads once the guard allows it', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+ let dirty = true;
+
+ renderBoundary({
+ duration: 10,
+ onUpdate: 'reload',
+ canReload: () => !dirty
+ });
+ await waitUntilOutdated();
+
+ dirty = false;
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+});
+
+describe('blocking', () => {
+ it('renders the app at once when off', () => {
+ mockFetch(() => metaResponse('v1'));
+
+ renderBoundary({ blocking: false });
+
+ expect(screen.queryByText('Checking version')).not.toBeInTheDocument();
+ expect(screen.getByTestId('loading')).toHaveTextContent('true');
+ });
+
+ it('still reloads a stale build found on mount', async () => {
+ mockFetch(() => metaResponse('v2'));
+
+ renderBoundary({ blocking: false });
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+});
+
+describe('onUpdate', () => {
+ function Prompt() {
+ return Update
;
+ }
+
+ it('defaults to asking when a prompt is given', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10, prompt: Prompt });
+
+ expect(await screen.findByRole('status')).toBeInTheDocument();
+ });
+
+ it('`flag` keeps the prompt hidden after a poll', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10, onUpdate: 'flag', prompt: Prompt });
+ await waitUntilOutdated();
+
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ it('`reload` asks instead when `canReload` holds the reload back', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({
+ duration: 10,
+ onUpdate: 'reload',
+ prompt: Prompt,
+ canReload: () => false
+ });
+
+ expect(await screen.findByRole('status')).toBeInTheDocument();
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ });
+
+ it('keeps the deprecated `auto` working', async () => {
+ mockFetch(
+ () => metaResponse('v1'),
+ () => metaResponse('v2')
+ );
+
+ renderBoundary({ duration: 10, auto: true });
+
+ await waitFor(() =>
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v2')
+ );
+ });
+});
+
+describe('blocking={false} on mount', () => {
+ it('asks `canReload` before reloading a stale build', async () => {
+ mockFetch(() => metaResponse('v2'));
+
+ renderBoundary({ blocking: false, canReload: () => false });
+ await waitUntilOutdated();
+
+ expect(window.localStorage.getItem(STORAGE_KEY)).toBe('v1');
+ });
+});
diff --git a/tsdown.config.mts b/tsdown.config.mts
index 8275c28..bc59d8d 100644
--- a/tsdown.config.mts
+++ b/tsdown.config.mts
@@ -7,5 +7,8 @@ export default defineConfig({
target: 'es2020',
fixedExtension: true,
dts: true,
- publint: true
+ publint: true,
+ // Next.js App Router treats files without it as server components, where
+ // the boundary's class state and hooks are not allowed.
+ outputOptions: { banner: "'use client';" }
});