Skip to content

Commit 1e22dac

Browse files
committed
feat!: bring-your-own update prompt, Next.js support and a redesigned demo
Rename the update API to one strategy prop: onUpdate ('prompt' | 'reload' | 'flag') with prompt and promptOnChunkError; auto stays as a deprecated alias. The library no longer ships a dialog component: prompt takes the app's own component, and the demo's UpdateDialog is an example to copy. Read the stored version after mount so the boundary renders on the server and hydrates without a mismatch, mark the bundle 'use client', and recognise Turbopack chunk errors. With reloadWhenHidden and duration, going to the background checks once, so a deploy since the last poll is reloaded into. lazyRetry keeps its 0.x meaning: the second argument counts attempts in all, two by default, and 0 makes one attempt instead of retrying forever. The 0.x ClearBrowserCacheAppVersionStorage type is exported again as deprecated. The demo uses radio groups for duration, onUpdate and the prompt, a compact layout, and a redesigned dialog.
1 parent 0044ae4 commit 1e22dac

22 files changed

Lines changed: 1295 additions & 830 deletions

‎README.md‎

Lines changed: 116 additions & 81 deletions
Large diffs are not rendered by default.

‎e2e/clearBrowserCache.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,11 @@ test.describe('update prompt', () => {
204204
expect(await storedVersion(page)).toBe('v2');
205205
});
206206

207-
test('promptOnError asks before reloading when a chunk is gone', async ({
207+
test('promptOnChunkError asks before reloading when a chunk is gone', async ({
208208
page
209209
}) => {
210210
const server = await deployServer(page, 'v1');
211-
await page.goto('/?prompt=1&promptOnError=1');
211+
await page.goto('/?prompt=1&promptOnChunkError=1');
212212
await expect(page.getByTestId('lazy')).toBeVisible();
213213

214214
server.deploy('v1', 'v2');

‎example/src/UpdateDialog.css‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/* Falls back to its own colors where the page defines no design tokens. */
2+
.update-dialog {
3+
--dialog-accent: var(--accent, #2f6fde);
4+
--dialog-accent-text: var(--accent-text, #fff);
5+
--dialog-surface: var(--surface, #fff);
6+
--dialog-text: var(--text, #1b1f24);
7+
--dialog-muted: var(--muted, #5b6573);
8+
--dialog-border: var(--border, #dfe3e8);
9+
--dialog-soft: var(--code-bg, #eef1f5);
10+
11+
width: min(24rem, calc(100vw - 32px));
12+
padding: 1.5rem;
13+
border: 1px solid var(--dialog-border);
14+
border-radius: 18px;
15+
background: var(--dialog-surface);
16+
color: var(--dialog-text);
17+
box-shadow:
18+
0 24px 60px rgb(16 24 40 / 0.22),
19+
0 2px 6px rgb(16 24 40 / 0.08);
20+
text-align: center;
21+
}
22+
23+
.update-dialog[open] {
24+
animation: update-dialog-in 0.18s ease-out;
25+
}
26+
27+
.update-dialog::backdrop {
28+
background: rgb(15 20 30 / 0.4);
29+
backdrop-filter: blur(3px);
30+
}
31+
32+
@keyframes update-dialog-in {
33+
from {
34+
opacity: 0;
35+
transform: translateY(8px) scale(0.98);
36+
}
37+
}
38+
39+
@media (prefers-reduced-motion: reduce) {
40+
.update-dialog[open] {
41+
animation: none;
42+
}
43+
}
44+
45+
.update-dialog-icon {
46+
display: grid;
47+
place-items: center;
48+
width: 2.75rem;
49+
height: 2.75rem;
50+
margin: 0 auto 0.9rem;
51+
border-radius: 50%;
52+
background: color-mix(in srgb, var(--dialog-accent) 12%, transparent);
53+
color: var(--dialog-accent);
54+
}
55+
56+
.update-dialog[data-reason='error'] .update-dialog-icon {
57+
background: color-mix(in srgb, #d97706 14%, transparent);
58+
color: #d97706;
59+
}
60+
61+
/* Doubled class: the dialog renders wherever the boundary is, so it must beat
62+
heading styles of the surrounding page. */
63+
.update-dialog.update-dialog h2 {
64+
margin: 0 0 0.4rem;
65+
color: var(--dialog-text);
66+
font-size: 1.15rem;
67+
font-weight: 700;
68+
letter-spacing: -0.01em;
69+
text-transform: none;
70+
}
71+
72+
.update-dialog p {
73+
margin: 0;
74+
color: var(--dialog-muted);
75+
font-size: 0.9rem;
76+
line-height: 1.5;
77+
}
78+
79+
.update-dialog-version {
80+
margin-top: 0.75rem !important;
81+
font-size: 0.8rem !important;
82+
}
83+
84+
.update-dialog-version code {
85+
padding: 0.1rem 0.4rem;
86+
border-radius: 6px;
87+
background: var(--dialog-soft);
88+
color: var(--dialog-text);
89+
}
90+
91+
.update-dialog-actions {
92+
display: grid;
93+
grid-template-columns: 1fr 1fr;
94+
gap: 0.5rem;
95+
margin-top: 1.25rem;
96+
}
97+
98+
.update-dialog-actions button {
99+
padding: 0.6rem 1rem;
100+
border: 1px solid var(--dialog-border);
101+
border-radius: 10px;
102+
background: var(--dialog-surface);
103+
color: var(--dialog-text);
104+
font: inherit;
105+
font-weight: 600;
106+
cursor: pointer;
107+
}
108+
109+
.update-dialog-actions button:disabled {
110+
cursor: progress;
111+
opacity: 0.7;
112+
}
113+
114+
.update-dialog-actions .update-dialog-primary {
115+
border-color: var(--dialog-accent);
116+
background: var(--dialog-accent);
117+
color: var(--dialog-accent-text);
118+
}
119+
120+
.update-dialog-actions button:focus-visible {
121+
outline: 2px solid var(--dialog-accent);
122+
outline-offset: 2px;
123+
}

‎example/src/UpdateDialog.tsx‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useEffect, useId, useRef, useState } from 'react';
2+
import type {
3+
UpdatePromptProps,
4+
UpdatePromptReason
5+
} from 'react-clear-browser-cache';
6+
7+
import './UpdateDialog.css';
8+
9+
const messages: Record<UpdatePromptReason, string> = {
10+
poll: 'Reload to get the latest fixes and features. It only takes a second.',
11+
error:
12+
'Part of the app could not load because a new version was released. Reload to continue.'
13+
};
14+
15+
// An example prompt to copy into your app: the library ships none, so the
16+
// prompt can match your design system.
17+
export function UpdateDialog({
18+
latestVersion,
19+
reason,
20+
reload,
21+
dismiss
22+
}: UpdatePromptProps) {
23+
const dialogRef = useRef<HTMLDialogElement>(null);
24+
const reloadRef = useRef<HTMLButtonElement>(null);
25+
const [reloading, setReloading] = useState(false);
26+
const titleId = useId();
27+
const messageId = useId();
28+
29+
// Declared before the opening effect so it records focus from before the
30+
// dialog took it. The dialog is removed rather than closed, and removal does
31+
// not hand focus back the way close() does.
32+
useEffect(() => {
33+
const previouslyFocused = document.activeElement;
34+
35+
return () => {
36+
if (previouslyFocused instanceof HTMLElement) {
37+
previouslyFocused.focus();
38+
}
39+
};
40+
}, []);
41+
42+
useEffect(() => {
43+
const dialog = dialogRef.current;
44+
45+
// StrictMode's second run finds it open already.
46+
if (dialog && !dialog.open) {
47+
dialog.showModal();
48+
}
49+
50+
// React applies autoFocus before the dialog opens, when nothing inside it
51+
// can take focus, and opening then focuses the first button instead.
52+
reloadRef.current?.focus();
53+
}, []);
54+
55+
// Escape while the reload is under way would otherwise hide the prompt, or
56+
// hand the error to the parent boundary, just before the page reloads.
57+
const dismissUnlessReloading = () => {
58+
if (!reloading) {
59+
dismiss();
60+
}
61+
};
62+
63+
return (
64+
<dialog
65+
ref={dialogRef}
66+
className='update-dialog'
67+
data-reason={reason}
68+
aria-labelledby={titleId}
69+
aria-describedby={messageId}
70+
onCancel={(event) => {
71+
event.preventDefault();
72+
dismissUnlessReloading();
73+
}}
74+
>
75+
<div className='update-dialog-icon' aria-hidden='true'>
76+
<svg viewBox='0 0 24 24' width='22' height='22'>
77+
<path
78+
d='M20 11a8 8 0 1 0-2.3 5.7M20 5v6h-6'
79+
fill='none'
80+
stroke='currentColor'
81+
strokeWidth='2'
82+
strokeLinecap='round'
83+
strokeLinejoin='round'
84+
/>
85+
</svg>
86+
</div>
87+
<h2 id={titleId}>A new version is available</h2>
88+
<p id={messageId}>{messages[reason]}</p>
89+
<p className='update-dialog-version'>
90+
Version <code>{latestVersion}</code>
91+
</p>
92+
<div className='update-dialog-actions'>
93+
<button type='button' onClick={dismiss} disabled={reloading}>
94+
Later
95+
</button>
96+
<button
97+
ref={reloadRef}
98+
type='button'
99+
className='update-dialog-primary'
100+
aria-busy={reloading}
101+
disabled={reloading}
102+
onClick={() => {
103+
setReloading(true);
104+
reload();
105+
}}
106+
>
107+
Reload
108+
</button>
109+
</div>
110+
</dialog>
111+
);
112+
}

0 commit comments

Comments
 (0)