diff --git a/example/package.json b/example/package.json
index 3ae02e6..4727d4e 100644
--- a/example/package.json
+++ b/example/package.json
@@ -10,6 +10,7 @@
],
"scripts": {
"build": "qwik build",
+ "build-prompt": "PROMPT=true qwik build",
"build.client": "vite build",
"build.preview": "vite build --ssr src/entry.preview.tsx",
"build.server": "vite build -c adapters/cloudflare-pages/vite.config.ts",
@@ -20,6 +21,7 @@
"fmt": "prettier --write .",
"fmt.check": "prettier --check .",
"preview": "qwik build preview && vite preview",
+ "preview-prompt": "PROMPT=true qwik build preview && vite preview",
"serve": "wrangler pages dev ./dist",
"start": "vite --open --mode ssr",
"qwik": "qwik"
diff --git a/example/src/components/pwa-prompt/pwa-prompt.module.css b/example/src/components/pwa-prompt/pwa-prompt.module.css
new file mode 100644
index 0000000..bb338c6
--- /dev/null
+++ b/example/src/components/pwa-prompt/pwa-prompt.module.css
@@ -0,0 +1,34 @@
+.ReloadPrompt-container {
+ padding: 0;
+ margin: 0;
+ width: 0;
+ display: block;
+ height: 0;
+}
+.ReloadPrompt-date {
+ visibility: hidden;
+}
+.ReloadPrompt-toast {
+ color: var(--qwik-light-blue);
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ margin: 16px;
+ padding: 12px;
+ border: 1px solid #8885;
+ border-radius: 4px;
+ z-index: 1;
+ text-align: left;
+ box-shadow: 3px 4px 5px 0 #8885;
+ background-color: var(--qwik-dark-background);
+}
+.ReloadPrompt-toast-message {
+ margin-bottom: 8px;
+}
+.ReloadPrompt-toast-button {
+ border: 1px solid #8885;
+ outline: none;
+ margin-right: 5px;
+ border-radius: 2px;
+ padding: 3px 10px;
+}
diff --git a/example/src/components/pwa-prompt/pwa-prompt.tsx b/example/src/components/pwa-prompt/pwa-prompt.tsx
new file mode 100644
index 0000000..391a947
--- /dev/null
+++ b/example/src/components/pwa-prompt/pwa-prompt.tsx
@@ -0,0 +1,42 @@
+import styles from "./pwa-prompt.module.css";
+import { $, component$ } from "@builder.io/qwik";
+import { usePromptForUpdate } from "@qwikdev/pwa/prompt-for-update";
+
+export default component$(() => {
+ // qwik pwa prompt for update
+ const { prompt } = usePromptForUpdate();
+
+ const hidePrompt = $(() => {
+ prompt.value = false;
+ });
+ const loadNewVersion = $(() => {
+ // @ts-ignore ignore globals for now
+ window.loadNewVersion();
+ });
+
+ return (
+
+ {prompt.value && (
+
+
+
+ New content available, click on reload button to update.
+
+
+
+
+
+ )}
+
+ );
+});
diff --git a/example/src/routes/layout.tsx b/example/src/routes/layout.tsx
index 1ca5f36..e9b1820 100644
--- a/example/src/routes/layout.tsx
+++ b/example/src/routes/layout.tsx
@@ -4,6 +4,7 @@ import type { RequestHandler } from "@builder.io/qwik-city";
import Header from "~/components/starter/header/header";
import Footer from "~/components/starter/footer/footer";
+import PWAPrompt from "~/components/pwa-prompt/pwa-prompt";
import styles from "./styles.css?inline";
@@ -32,6 +33,7 @@ export default component$(() => {
+
>
);
diff --git a/example/vite.config.ts b/example/vite.config.ts
index c70e9af..14ab302 100644
--- a/example/vite.config.ts
+++ b/example/vite.config.ts
@@ -4,12 +4,15 @@ import { qwikCity } from "@builder.io/qwik-city/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { type PWAOptions, qwikPwa } from "@qwikdev/pwa";
+const promptForUpdate = process.env.PROMPT === "true";
+
const config: PWAOptions | undefined = process.env.CUSTOM_CONFIG === "true"
- ? { config: true }
- : undefined;
+ ? { config: true, promptForUpdate }
+ : { config: false, promptForUpdate, preset: "minimal-2023" };
export default defineConfig(() => {
return {
+ mode: "development",
define: {
// enables debugging in workbox
"process.env.NODE_ENV": JSON.stringify("development"),
diff --git a/package.json b/package.json
index d04783d..c314cd6 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,16 @@
"default": "./lib/sw.qwik.cjs"
}
},
+ "./prompt-for-update": {
+ "import": {
+ "types": "./lib-types/prompt-for-update.d.mts",
+ "default": "./lib/prompt-for-update.qwik.js"
+ },
+ "require": {
+ "types": "./lib-types/prompt-for-update.d.cts",
+ "default": "./lib/prompt-for-update.qwik.cjs"
+ }
+ },
"./*": "./*"
},
"main": "lib/index.qwik.js",
@@ -57,6 +67,9 @@
"head": [
"./lib-types/head.d.ts"
],
+ "prompt-for-update": [
+ "./lib-types/prompt-for-update.d.ts"
+ ],
"sw": [
"./lib-types/sw.d.ts"
],
diff --git a/scripts/postbuild.mjs b/scripts/postbuild.mjs
index 3a4e1c9..a589a25 100644
--- a/scripts/postbuild.mjs
+++ b/scripts/postbuild.mjs
@@ -11,6 +11,12 @@ async function postBuild() {
writeFile("./lib-types/head.d.mts", content, "utf-8"),
]);
}),
+ readFile("./lib-types/prompt-for-update.d.ts", "utf-8").then((content) => {
+ return Promise.all([
+ writeFile("./lib-types/prompt-for-update.d.cts", content, "utf-8"),
+ writeFile("./lib-types/prompt-for-update.d.mts", content, "utf-8"),
+ ]);
+ }),
readFile("./lib-types/index.d.ts", "utf-8").then((content) => {
return Promise.all([
writeFile("./lib-types/index.d.cts", content, "utf-8"),
diff --git a/src/assets/options.ts b/src/assets/options.ts
index 37fdc59..4734a1f 100644
--- a/src/assets/options.ts
+++ b/src/assets/options.ts
@@ -17,6 +17,7 @@ export function resolveOptions(ctx: QwikPWAContext) {
includeHtmlHeadLinks = true,
includeThemeColor = true,
includeWebManifest = false,
+ promptForUpdate = false,
} = ctx.userOptions;
ctx.options = {
@@ -30,5 +31,6 @@ export function resolveOptions(ctx: QwikPWAContext) {
includeHtmlHeadLinks,
includeThemeColor,
includeWebManifest,
+ promptForUpdate,
};
}
diff --git a/src/plugins/client.ts b/src/plugins/client.ts
index 4bae94e..1f1f9a7 100644
--- a/src/plugins/client.ts
+++ b/src/plugins/client.ts
@@ -26,6 +26,7 @@ export default function ClientPlugin(ctx: QwikPWAContext): Plugin {
const swCodeUpdate = `
const excludeAssets = ['_headers', '_redirects']
const version = ${JSON.stringify(ctx.version)};
+ const promptForUpdate = ${ctx.userOptions.promptForUpdate === true};
const publicDirAssets = ${JSON.stringify(publicDirAssets)};
const emittedAssets = ${JSON.stringify([
...generatedAssetsUrls,
diff --git a/src/plugins/main.ts b/src/plugins/main.ts
index c4bd2f9..574b105 100644
--- a/src/plugins/main.ts
+++ b/src/plugins/main.ts
@@ -1,6 +1,9 @@
import { initializeContext, type QwikPWAContext } from "../context";
import type { Plugin } from "vite";
+const VIRTUAL_CLIENT_MODE = "virtual:qwik-pwa/client-mode";
+const RESOLVED_VIRTUAL_CLIENT_MODE = `\0${VIRTUAL_CLIENT_MODE}`;
+
export default function MainPlugin(ctx: QwikPWAContext): Plugin {
return {
name: "qwik-pwa:main",
@@ -8,5 +11,15 @@ export default function MainPlugin(ctx: QwikPWAContext): Plugin {
configResolved(config) {
initializeContext(ctx, config);
},
+ resolveId(id) {
+ if (id === VIRTUAL_CLIENT_MODE) return RESOLVED_VIRTUAL_CLIENT_MODE;
+ },
+ load(id) {
+ if (id === RESOLVED_VIRTUAL_CLIENT_MODE) {
+ return `export const promptForUpdate = ${
+ ctx.userOptions.promptForUpdate === true
+ };`;
+ }
+ },
};
}
diff --git a/src/prompt-for-update.ts b/src/prompt-for-update.ts
new file mode 100644
index 0000000..9348ab6
--- /dev/null
+++ b/src/prompt-for-update.ts
@@ -0,0 +1,59 @@
+import { $, useOnDocument, useSignal } from "@builder.io/qwik";
+// @ts-ignore
+import { promptForUpdate } from "virtual:qwik-pwa/client-mode";
+
+export function usePromptForUpdate() {
+ const prompt = useSignal(false);
+
+ useOnDocument(
+ "load",
+ $(() => {
+ // if using auto-update, remove the prompt logic
+ if (promptForUpdate === true && "serviceWorker" in navigator) {
+ let swr: ServiceWorkerRegistration | null;
+ // @ts-ignore ignore globals for now
+ window.loadNewVersion = () => {
+ console.log("loadNewVersion", {
+ prompt: prompt.value,
+ waiting: swr?.waiting,
+ state: swr?.waiting?.state,
+ });
+ if (prompt.value && swr?.waiting?.state === "installed") {
+ swr.waiting.postMessage({ type: "SKIP_WAITING" });
+ }
+ };
+ navigator.serviceWorker.ready.then((registration) => {
+ swr = registration;
+ registration.addEventListener("updatefound", () => {
+ const newSW = registration.installing;
+ newSW?.addEventListener("statechange", () => {
+ console.log("newSW.state", newSW?.state);
+ if (newSW?.state === "activated") {
+ window.location.reload();
+ }
+ if (newSW?.state === "installed") {
+ // New service worker is installed, but awaiting activation
+ prompt.value = true;
+ }
+ });
+ });
+ // if already installed, but still not activated
+ // for example, when the user cancels the prompt and then refreshes the page
+ if (registration.waiting?.state === "installed") {
+ prompt.value = true;
+ }
+ });
+ }
+ }),
+ );
+
+ return { prompt };
+}
+
+/* can we do this here in qwik?
+declare global {
+ interface Window {
+ loadNewVersion: () => void;
+ }
+}
+*/
diff --git a/src/sw.ts b/src/sw.ts
index df58ea1..6159c4a 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -12,29 +12,53 @@ import {
setDefaultHandler,
} from "workbox-routing";
-export const assets = [...publicDirAssets, ...emittedAssets].filter((asset) => !excludeAssets.includes(asset));
+const serviceWorkerName = self.location.pathname.replace(/^\//, "");
+const useExcludedAssets = [
+ ...excludeAssets,
+ serviceWorkerName,
+ `build/${serviceWorkerName}`,
+];
+
+const buildMap: Map = appBundles.reduce(
+ (map, bundle) => {
+ const bundleName = bundle[0];
+ if (useExcludedAssets.includes(bundleName)) return map;
+
+ const revision = bundle.length === 3 && bundle[2].length;
+ map.set(bundleName, revision ? null : manifestHash);
+ map.set(`build/${bundleName}`, revision ? null : manifestHash);
+ return map;
+ },
+ new Map(),
+);
+// console.log("buildMap", buildMap);
+export const assets = [...publicDirAssets, ...emittedAssets].filter(
+ (asset) => !useExcludedAssets.includes(asset),
+);
+
export { routes };
+// TODO: check what happens with urls when using a custom base in Vite/Qwik
+const urlsToEntriesMatcher = /^build\/q-/;
function urlsToEntries(urls: string[], hash: string): PrecacheEntry[] {
- const matcher = /^build\/q-/;
return urls.map((url) => {
- const match = url.match(matcher);
// use null revision, removing the revision or using undefined will cause workbox warnings in runtime
- return match ? { url, revision: null } : { url, revision: hash };
+ return buildMap.has(url)
+ ? { url, revision: buildMap.get(url) }
+ : { url, revision: url.match(urlsToEntriesMatcher) ? null : hash };
});
}
/**
* Add PWA capabilities.
- *
- * **WARNING**: "prompt" mode not available yet.
- *
- * @param mode
- * @default "auto-update"
*/
-export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") {
+export function setupPwa() {
if (import.meta.env.DEV) {
- console.info(`Qwik PWA v${version}, using ${mode} strategy`);
+ console.info(
+ `Qwik PWA v${version}, using ${
+ promptForUpdate ? "prompt-for-update" : "auto-update"
+ } strategy`,
+ );
}
const noParamRoutes = routes.filter((r) => !r.hasParams);
@@ -62,32 +86,24 @@ export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") {
registerRoute(route.pattern, new StaleWhileRevalidate());
}
- if (mode === "prompt") {
- if (import.meta.env.DEV) {
- console.warn(
- `Qwik PWA v${version}\nWARNING: "prompt" mode not available yet`,
- );
- }
- /*
+ if (promptForUpdate) {
self.addEventListener("message", (event) => {
if (event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
- */
- }
- // else {
- // Skip-Waiting Service Worker-based solution
- self.addEventListener("activate", async () => {
- // after we've taken over, iterate over all the current clients (windows)
- const clients = await self.clients.matchAll({ type: "window" });
- clients.forEach((client) => {
- // ...and refresh each one of them
- client.navigate(client.url);
+ } else {
+ // Skip-Waiting Service Worker-based solution
+ self.addEventListener("activate", async () => {
+ // after we've taken over, iterate over all the current clients (windows)
+ const clients = await self.clients.matchAll({ type: "window" });
+ clients.forEach((client) => {
+ // ...and refresh each one of them
+ client.navigate(client.url);
+ });
});
- });
- self.skipWaiting();
- // }
+ self.skipWaiting();
+ }
const base = "/build/"; // TODO: it should be dynamic based on the build
const qprefetchEvent = new MessageEvent("message", {
@@ -103,6 +119,7 @@ export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") {
}
declare const version: string;
+declare const promptForUpdate: boolean;
declare const appBundles: AppBundle[];
declare const excludeAssets: string[];
diff --git a/src/types.ts b/src/types.ts
index 0e0997a..5377bc9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -92,4 +92,10 @@ export interface PWAOptions {
* @default false
*/
includeWebManifest?: boolean;
+ /**
+ *
+ *
+ * @default false
+ */
+ promptForUpdate?: boolean;
}
diff --git a/vite.config.ts b/vite.config.ts
index 89d0292..4968b95 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -13,7 +13,12 @@ export default defineConfig({
build: {
target: "es2020",
lib: {
- entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts"],
+ entry: [
+ "./src/index.ts",
+ "./src/sw.ts",
+ "./src/head.ts",
+ "./src/prompt-for-update.ts",
+ ],
formats: ["es", "cjs"],
fileName: (format, entryName) =>
`${entryName}.qwik.${format === "es" ? "js" : "cjs"}`,
@@ -23,6 +28,7 @@ export default defineConfig({
external: [
"fast-glob",
"virtual:qwik-pwa/head",
+ "virtual:qwik-pwa/client-mode",
...excludeAll(builtinModules),
...builtinModules,
/^node:.*/,