Skip to content

Commit bbe69d7

Browse files
authored
feat(sw): show additional precache progress toast (#88)
1 parent bb3eb6e commit bbe69d7

7 files changed

Lines changed: 341 additions & 1 deletion

File tree

app/providers.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { SerwistProvider } from "@serwist/turbopack/react";
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
55
import { createStore, Provider } from "jotai";
6+
import { AdditionalPrecacheProgressToast } from "@/components/additional-precache-progress-toast";
67
import { BuildUpdateToast } from "@/components/build-update-toast";
78
import { GlobalDialogHost } from "@/components/global-dialog-host";
89
import { I18nProvider } from "@/components/i18n-provider";
@@ -30,6 +31,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
3031
<TooltipProvider>
3132
{children}
3233
<Toaster />
34+
<AdditionalPrecacheProgressToast />
3335
<BuildUpdateToast />
3436
<GlobalDialogHost />
3537
</TooltipProvider>

app/sw.js/route.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,29 @@ const revision =
77
encoding: "utf-8",
88
}).stdout.trim() || crypto.randomUUID();
99
const additionalPrecacheEntries = await getPrecacheEntries(revision);
10+
const additionalPrecacheUrls =
11+
process.env.NODE_ENV === "development"
12+
? []
13+
: Array.from(
14+
new Set(
15+
additionalPrecacheEntries.map((entry) =>
16+
typeof entry === "string" ? entry : entry.url,
17+
),
18+
),
19+
);
1020

1121
const route = createSerwistRoute({
1222
additionalPrecacheEntries,
1323
swSrc: "app/sw.ts",
1424
useNativeEsbuild: true,
1525
maximumFileSizeToCacheInBytes: 7 * 1024 * 1024,
26+
esbuildOptions: {
27+
define: {
28+
__FINITO_ADDITIONAL_PRECACHE_URLS__: JSON.stringify(
29+
additionalPrecacheUrls,
30+
),
31+
},
32+
},
1633
});
1734

1835
export const { dynamic, dynamicParams, revalidate } = route;

app/sw.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@
22
/// <reference lib="esnext" />
33
/// <reference lib="webworker" />
44
import { defaultCache } from "@serwist/turbopack/worker";
5-
import { type PrecacheEntry, Serwist, type SerwistGlobalConfig } from "serwist";
5+
import {
6+
type PrecacheEntry,
7+
Serwist,
8+
type SerwistGlobalConfig,
9+
type SerwistPlugin,
10+
} from "serwist";
11+
import {
12+
ADDITIONAL_PRECACHE_PROGRESS_MESSAGE,
13+
type AdditionalPrecacheProgressMessage,
14+
type AdditionalPrecacheProgressPayload,
15+
isGetAdditionalPrecacheProgressMessage,
16+
} from "@/lib/serwist/additional-precache-progress";
617

718
// This declares the value of `injectionPoint` to TypeScript.
819
// `injectionPoint` is the string that will be replaced by the
@@ -15,6 +26,78 @@ declare global {
1526
}
1627

1728
declare const self: ServiceWorkerGlobalScope;
29+
declare const __FINITO_ADDITIONAL_PRECACHE_URLS__: string[];
30+
31+
const additionalPrecacheUrlSet = new Set(
32+
__FINITO_ADDITIONAL_PRECACHE_URLS__.map(
33+
(url) => new URL(url, self.location.origin).href,
34+
),
35+
);
36+
37+
let completedAdditionalPrecacheUrls = new Set<string>();
38+
let additionalPrecacheProgress: AdditionalPrecacheProgressPayload = {
39+
completed: 0,
40+
status: "idle",
41+
total: additionalPrecacheUrlSet.size,
42+
};
43+
44+
const createAdditionalPrecacheProgressMessage =
45+
(): AdditionalPrecacheProgressMessage => ({
46+
type: ADDITIONAL_PRECACHE_PROGRESS_MESSAGE,
47+
payload: additionalPrecacheProgress,
48+
});
49+
50+
const broadcastAdditionalPrecacheProgress = async () => {
51+
const clients = await self.clients.matchAll({
52+
type: "window",
53+
includeUncontrolled: true,
54+
});
55+
const message = createAdditionalPrecacheProgressMessage();
56+
57+
for (const client of clients) {
58+
client.postMessage(message);
59+
}
60+
};
61+
62+
const resetAdditionalPrecacheProgress = () => {
63+
completedAdditionalPrecacheUrls = new Set();
64+
additionalPrecacheProgress = {
65+
completed: 0,
66+
status: additionalPrecacheUrlSet.size > 0 ? "running" : "idle",
67+
total: additionalPrecacheUrlSet.size,
68+
};
69+
};
70+
71+
const additionalPrecacheProgressPlugin: SerwistPlugin = {
72+
handlerDidComplete: async ({ event, error, request }) => {
73+
if (event.type !== "install") return;
74+
if (!additionalPrecacheUrlSet.has(request.url)) return;
75+
if (additionalPrecacheProgress.status === "error") return;
76+
77+
if (error) {
78+
additionalPrecacheProgress = {
79+
...additionalPrecacheProgress,
80+
status: "error",
81+
};
82+
await broadcastAdditionalPrecacheProgress();
83+
return;
84+
}
85+
86+
if (completedAdditionalPrecacheUrls.has(request.url)) return;
87+
88+
completedAdditionalPrecacheUrls.add(request.url);
89+
additionalPrecacheProgress = {
90+
completed: completedAdditionalPrecacheUrls.size,
91+
status:
92+
completedAdditionalPrecacheUrls.size === additionalPrecacheUrlSet.size
93+
? "complete"
94+
: "running",
95+
total: additionalPrecacheUrlSet.size,
96+
};
97+
98+
await broadcastAdditionalPrecacheProgress();
99+
},
100+
};
18101

19102
const serwist = new Serwist({
20103
precacheEntries: self.__SW_MANIFEST,
@@ -38,4 +121,23 @@ const serwist = new Serwist({
38121
},
39122
});
40123

124+
serwist.precacheStrategy.plugins.push(additionalPrecacheProgressPlugin);
125+
126+
self.addEventListener("install", (event) => {
127+
if (additionalPrecacheUrlSet.size === 0) return;
128+
129+
resetAdditionalPrecacheProgress();
130+
event.waitUntil(broadcastAdditionalPrecacheProgress());
131+
});
132+
133+
self.addEventListener("message", (event) => {
134+
if (!isGetAdditionalPrecacheProgressMessage(event.data)) return;
135+
136+
event.waitUntil(
137+
(async () => {
138+
event.ports[0]?.postMessage(createAdditionalPrecacheProgressMessage());
139+
})(),
140+
);
141+
});
142+
41143
serwist.addEventListeners();
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"use client";
2+
3+
import { useSerwist } from "@serwist/turbopack/react";
4+
import { useEffect, useEffectEvent, useRef } from "react";
5+
import { useTranslation } from "react-i18next";
6+
import { toast } from "sonner";
7+
import {
8+
type AdditionalPrecacheProgressPayload,
9+
GET_ADDITIONAL_PRECACHE_PROGRESS_MESSAGE,
10+
isAdditionalPrecacheProgressMessage,
11+
} from "@/lib/serwist/additional-precache-progress";
12+
13+
const TOAST_ID = "additional-precache-progress";
14+
15+
export const AdditionalPrecacheProgressToast = () => {
16+
const { serwist } = useSerwist();
17+
const { t } = useTranslation("app");
18+
const hasVisibleToastRef = useRef(false);
19+
20+
const dismissToast = useEffectEvent(() => {
21+
if (!hasVisibleToastRef.current) return;
22+
23+
toast.dismiss(TOAST_ID);
24+
hasVisibleToastRef.current = false;
25+
});
26+
27+
const showRunningToast = useEffectEvent(
28+
(payload: AdditionalPrecacheProgressPayload) => {
29+
const percent =
30+
payload.total === 0
31+
? 0
32+
: Math.round((payload.completed / payload.total) * 100);
33+
34+
toast.loading(t("precacheProgress.running.title"), {
35+
id: TOAST_ID,
36+
description: (
37+
<div className="w-full space-y-1.5">
38+
<div className="flex w-full items-center gap-3">
39+
<p className="text-xs font-normal text-muted-foreground">
40+
{t("precacheProgress.running.description", {
41+
completed: payload.completed,
42+
total: payload.total,
43+
})}
44+
</p>
45+
<p className="ml-auto text-xs text-muted-foreground tabular-nums">
46+
{percent}%
47+
</p>
48+
</div>
49+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
50+
<div
51+
className="h-full rounded-full bg-primary transition-[width]"
52+
style={{ width: `${percent}%` }}
53+
/>
54+
</div>
55+
</div>
56+
),
57+
dismissible: false,
58+
duration: Number.POSITIVE_INFINITY,
59+
onDismiss: () => {
60+
hasVisibleToastRef.current = false;
61+
},
62+
});
63+
64+
hasVisibleToastRef.current = true;
65+
},
66+
);
67+
68+
const showCompletedToast = useEffectEvent(
69+
(payload: AdditionalPrecacheProgressPayload) => {
70+
if (!hasVisibleToastRef.current) return;
71+
72+
toast.success(t("precacheProgress.complete.title"), {
73+
id: TOAST_ID,
74+
description: t("precacheProgress.complete.description", {
75+
total: payload.total,
76+
}),
77+
duration: 4000,
78+
onAutoClose: () => {
79+
hasVisibleToastRef.current = false;
80+
},
81+
onDismiss: () => {
82+
hasVisibleToastRef.current = false;
83+
},
84+
});
85+
},
86+
);
87+
88+
const handleProgress = useEffectEvent(
89+
(payload: AdditionalPrecacheProgressPayload) => {
90+
if (payload.total === 0 || payload.status === "idle") {
91+
dismissToast();
92+
return;
93+
}
94+
95+
if (payload.status === "running") {
96+
showRunningToast(payload);
97+
return;
98+
}
99+
100+
if (payload.status === "complete") {
101+
showCompletedToast(payload);
102+
return;
103+
}
104+
105+
dismissToast();
106+
},
107+
);
108+
109+
useEffect(() => {
110+
if (!("serviceWorker" in navigator)) return;
111+
112+
const handleMessage = (event: MessageEvent<unknown>) => {
113+
if (!isAdditionalPrecacheProgressMessage(event.data)) return;
114+
115+
handleProgress(event.data.payload);
116+
};
117+
118+
navigator.serviceWorker.addEventListener("message", handleMessage);
119+
120+
void (async () => {
121+
if (!serwist) return;
122+
123+
try {
124+
const response = await serwist.messageSW({
125+
type: GET_ADDITIONAL_PRECACHE_PROGRESS_MESSAGE,
126+
});
127+
128+
if (isAdditionalPrecacheProgressMessage(response)) {
129+
handleProgress(response.payload);
130+
}
131+
} catch {
132+
// The worker might not be ready yet. Broadcast updates will still arrive.
133+
}
134+
})();
135+
136+
return () => {
137+
navigator.serviceWorker.removeEventListener("message", handleMessage);
138+
dismissToast();
139+
};
140+
}, [serwist]);
141+
142+
return null;
143+
};
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export const ADDITIONAL_PRECACHE_PROGRESS_MESSAGE =
2+
"finito.additionalPrecacheProgress";
3+
export const GET_ADDITIONAL_PRECACHE_PROGRESS_MESSAGE =
4+
"finito.getAdditionalPrecacheProgress";
5+
6+
export type AdditionalPrecacheProgressStatus =
7+
| "idle"
8+
| "running"
9+
| "complete"
10+
| "error";
11+
12+
export interface AdditionalPrecacheProgressPayload {
13+
completed: number;
14+
status: AdditionalPrecacheProgressStatus;
15+
total: number;
16+
}
17+
18+
export interface AdditionalPrecacheProgressMessage {
19+
payload: AdditionalPrecacheProgressPayload;
20+
type: typeof ADDITIONAL_PRECACHE_PROGRESS_MESSAGE;
21+
}
22+
23+
export interface GetAdditionalPrecacheProgressMessage {
24+
type: typeof GET_ADDITIONAL_PRECACHE_PROGRESS_MESSAGE;
25+
}
26+
27+
function isObject(value: unknown): value is Record<string, unknown> {
28+
return typeof value === "object" && value !== null;
29+
}
30+
31+
export function isAdditionalPrecacheProgressMessage(
32+
value: unknown,
33+
): value is AdditionalPrecacheProgressMessage {
34+
if (!isObject(value)) return false;
35+
if (value.type !== ADDITIONAL_PRECACHE_PROGRESS_MESSAGE) return false;
36+
37+
const { payload } = value;
38+
if (!isObject(payload)) return false;
39+
40+
return (
41+
(payload.status === "idle" ||
42+
payload.status === "running" ||
43+
payload.status === "complete" ||
44+
payload.status === "error") &&
45+
typeof payload.completed === "number" &&
46+
typeof payload.total === "number"
47+
);
48+
}
49+
50+
export function isGetAdditionalPrecacheProgressMessage(
51+
value: unknown,
52+
): value is GetAdditionalPrecacheProgressMessage {
53+
return (
54+
isObject(value) && value.type === GET_ADDITIONAL_PRECACHE_PROGRESS_MESSAGE
55+
);
56+
}

locales/cs/app.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ const locale = {
1717
"later": "Později"
1818
}
1919
},
20+
"precacheProgress": {
21+
"running": {
22+
"title": "Připravuji offline soubory",
23+
"description": "V mezipaměti je {{completed}} z {{total}} souborů"
24+
},
25+
"complete": {
26+
"title": "Offline soubory jsou připravené",
27+
"description": "Do mezipaměti se uložilo {{total}} souborů pro rychlejší další načtení."
28+
}
29+
},
2030
"offline": {
2131
"title": "Jste offline",
2232
"description": "Tuto obrazovku zatím bez připojení nelze otevřít.",

0 commit comments

Comments
 (0)