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 - -[![NPM](https://img.shields.io/npm/v/react-clear-browser-cache.svg)](https://www.npmjs.com/package/react-clear-browser-cache) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) +> Keep open tabs on your latest React build: notice a deploy, clear Cache Storage and reload, or ask the user first. + +[![npm version](https://img.shields.io/npm/v/react-clear-browser-cache.svg)](https://www.npmjs.com/package/react-clear-browser-cache) +[![npm downloads](https://img.shields.io/npm/dm/react-clear-browser-cache.svg)](https://www.npmjs.com/package/react-clear-browser-cache) +[![CI](https://github.com/devmaxxx/react-clear-browser-cache/actions/workflows/ci.yml/badge.svg)](https://github.com/devmaxxx/react-clear-browser-cache/actions/workflows/ci.yml) +[![bundle size](https://img.shields.io/bundlephobia/minzip/react-clear-browser-cache.svg)](https://bundlephobia.com/package/react-clear-browser-cache) +![types](https://img.shields.io/npm/types/react-clear-browser-cache.svg) +[![license](https://img.shields.io/npm/l/react-clear-browser-cache.svg)](#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. + + + The demo after a deploy: the update prompt asks to reload, the event log shows meta.json returning v2 while the tab still runs v1. + + +## 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.'} + + + + ); +} +``` + +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 : ( + + ); } + + + {({ 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} +

+
+ + +
+
+ ); +} 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} + +
+ ); + } + + 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 && ( + + )} + + ); +} + +function ReportsSection() { + const [open, setOpen] = useState(false); + + return ( +
+ + {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) => ( + + ))} +
+
+ ); +} + +function Toggle({ + checked, + disabled, + onChange, + name, + hint +}: { + checked: boolean; + disabled?: boolean; + onChange: (checked: boolean) => void; + name: ReactNode; + hint: string; +}) { + return ( + + ); +} + +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

+ +
+
    + {entries + .slice() + .reverse() + .map((entry) => ( +
  1. + + {entry.text} +
  2. + ))} +
+
+ ); +} + +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. +

+ +
+ +
    +
  1. + Deploy a new version. The server moves on; this tab + still runs {runningVersion}. +
  2. +
  3. + Wait for the next poll, or open the reports: their + chunk is gone, so loading them fails. +
  4. +
  5. + Reload from the prompt and watch the log. Open a + second tab to see deploys reach it too. +
  6. +
+ +
+
+

Server

+
+
+ Deployed + + {deployed} + +
+ +
+ This tab runs + + {runningVersion} + +
+
+
+ + +
+
+ +
+

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.`} + + + +
+ ); +} + +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';" } });