Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ node_modules

# builds
dist
dist-demo
build

# misc
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
dist
dist-demo
coverage
pnpm-lock.yaml
playwright-report
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
392 changes: 304 additions & 88 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/demo-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 72 additions & 0 deletions e2e/clearBrowserCache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
49 changes: 49 additions & 0 deletions e2e/demo.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
8 changes: 7 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
123 changes: 123 additions & 0 deletions example/src/UpdateDialog.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading