Skip to content
Draft
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
2 changes: 2 additions & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions example/src/components/pwa-prompt/pwa-prompt.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions example/src/components/pwa-prompt/pwa-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class={styles["ReloadPrompt-container"]}>
{prompt.value && (
<div class={styles["ReloadPrompt-toast"]}>
<div class={styles["ReloadPrompt-message"]}>
<span>
New content available, click on reload button to update.
</span>
</div>
<button
class={styles["ReloadPrompt-toast-button"]}
onClick$={() => loadNewVersion()}
>
Reload
</button>
<button
class={styles["ReloadPrompt-toast-button"]}
onClick$={() => hidePrompt()}
>
Close
</button>
</div>
)}
</div>
);
});
2 changes: 2 additions & 0 deletions example/src/routes/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -32,6 +33,7 @@ export default component$(() => {
<main>
<Slot />
</main>
<PWAPrompt />
<Footer />
</>
);
Expand Down
7 changes: 5 additions & 2 deletions example/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
],
Expand Down
6 changes: 6 additions & 0 deletions scripts/postbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions src/assets/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function resolveOptions(ctx: QwikPWAContext) {
includeHtmlHeadLinks = true,
includeThemeColor = true,
includeWebManifest = false,
promptForUpdate = false,
} = ctx.userOptions;

ctx.options = {
Expand All @@ -30,5 +31,6 @@ export function resolveOptions(ctx: QwikPWAContext) {
includeHtmlHeadLinks,
includeThemeColor,
includeWebManifest,
promptForUpdate,
};
}
1 change: 1 addition & 0 deletions src/plugins/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/plugins/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
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",
enforce: "pre",
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
};`;
}
},
};
}
59 changes: 59 additions & 0 deletions src/prompt-for-update.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
*/
79 changes: 48 additions & 31 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | null> = 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<string, string | null>(),
);
// 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)

@userquin userquin Jan 17, 2024

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should provide sw precaching manifest at build time, we don't need this logic at runtime (in client plugin we have all the required data to do it).

We also need to check why nr build + nr serve or nr build-prompt + nr serve are different from nr preview or nr preview-prompt (build command and/or serve is about SSG (??)?)

? { 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);
Expand Down Expand Up @@ -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<ServiceWorkerMessage>("message", {
Expand All @@ -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[];
Expand Down
Loading