diff --git a/AGENTS.md b/AGENTS.md
index 0339fb8..222da8c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -161,7 +161,7 @@ Set in Settings > Environment (super admin), not `.env`. `envs` (private; encryp
- **Install name:** app setting `install_name` (Settings > General, super admin). `handlePwaFile` rewrites the manifest's `name`/`short_name` per request (`namePwaManifest`), the root head uses it for iOS tags, the loading page's `
` is the `{{name}}` placeholder. Devices pick up a new name only on reinstall.
- **Install icon:** app setting `install_icon` (JSON, `parseInstallIcon`): a stone, or an upload (decoded, cropped to `source.png`, never stored as sent, no SVG) with the stone at `PwaCornerMarkInset`/`PwaCornerMarkScale`, inside a round launcher's crop. `saveInstallIcon` draws the set (`renderInstallIcons`, shared with `gen:favicon` via `renderAppIcon`) into `DATA_DIR/install-icon//`, served publicly and `immutable` by `handleInstallIconFile`; the manifest, head links and push payloads point at it. Ember stone = stock icons, no set.
- Notification `badge` is `icons/badge-96.png` (white silhouette; Android masks by alpha, so never the opaque app icon). Brave on Android has no WebAPK, so its notifications always carry Brave's name and icon.
-- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. It never reloads on its own. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
+- `public/sw.js` is served `no-store`; navigations network-only (authenticated HTML never cached); `/api/`, `/ws`, `/media/`, `/computers/`, `/build-info.json` never intercepted. A new worker takes over by itself (`skipWaiting` on install; nothing in it is per-version) and never reloads a page; there is no update prompt. In development the worker and caches are actively removed (`clear-development-service-worker.ts`, stub `/sw.js`).
- **Precache (production):** a navigation without the current build cookie gets `loading.html`, which downloads all of `.bunext/public` into `cobble-assets-`, verifies, sets the cookie and reloads (falls back to network on stall). React knows nothing of this.
- Phone shell: rail is a slide-over below `md`; rail internals use the `@rail:` container variant, not viewport `lg:`. `LiveSocket` is remounted on online/pageshow/visibility since twui stops retrying after three attempts. Nothing is queued offline.
- **Web Push:** `push_subscriptions`, `user_presence`; VAPID keys generated into `envs` by `ensureVapidKeys`. `pushBotNotice` is the one trigger, skipped while the user is present (`AppData.PushPresenceWindowMs`, per user not per device). Best effort, no retry. Needs HTTPS.
diff --git a/public/sw.js b/public/sw.js
index 7122c46..4b2c72a 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -85,7 +85,14 @@ function isBypassRequest(url) {
);
}
+/**
+ * Nothing the worker holds belongs to one version (navigations go to the
+ * network, the code cache belongs to the build), so a new worker takes
+ * over at once instead of waiting for every tab to close. It never
+ * reloads a page: the open one keeps running, now served by it.
+ */
self.addEventListener("install", (event) => {
+ self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then((cache) =>
Promise.allSettled(SHELL.map((url) => cache.add(url))),
@@ -190,9 +197,8 @@ async function cacheFirst(request) {
}
/**
- * The page asks for a new version from its update notice: nothing skips
- * the wait until the user says so. The loading page asks too, since it
- * isn't the app and nothing of the app's is running.
+ * The loading page asks a waiting worker to take over. Workers from
+ * before `install` skipped the wait on their own only answer this.
*/
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
diff --git a/src/components/general/service-worker-registrar.tsx b/src/components/general/service-worker-registrar.tsx
index 9e36905..d5f2899 100644
--- a/src/components/general/service-worker-registrar.tsx
+++ b/src/components/general/service-worker-registrar.tsx
@@ -1,11 +1,6 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import Paper from "@moduletrace/twui/elements/Paper";
-import Row from "@moduletrace/twui/layout/Row";
-import Span from "@moduletrace/twui/layout/Span";
-import Button from "@moduletrace/twui/layout/Button";
-import { RefreshCw } from "lucide-react";
+import { useEffect, useRef } from "react";
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
import clearDevelopmentServiceWorker from "@/src/functions/frontend/clear-development-service-worker";
@@ -24,8 +19,11 @@ const DevelopmentClearMaxAttempts = 3;
/**
* Registers the app's service worker (production only, so development
- * HMR is never cached) and offers a reload once a new version is
- * installed. It never reloads on its own: a bot reply may be running.
+ * HMR is never cached) and checks for a new one hourly and whenever the
+ * app comes back into view. A new worker takes over by itself
+ * (`public/sw.js`), so there is nothing to offer and nothing reloads: a
+ * bot reply may be running. New builds reach the device through the
+ * loading page.
*
* In development it does the opposite: it clears the worker and the
* caches a production deploy left on the device, so the dev server's
@@ -33,8 +31,6 @@ const DevelopmentClearMaxAttempts = 3;
* `clear-development-service-worker.ts`).
*/
export default function ServiceWorkerRegistrar() {
- const [update_ready, setUpdateReady] = useState(false);
- const [reloading, setReloading] = useState(false);
const registration = useRef(null);
useEffect(() => {
@@ -45,26 +41,6 @@ export default function ServiceWorkerRegistrar() {
let cancelled = false;
let interval: ReturnType | undefined;
- const watch = (reg: ServiceWorkerRegistration) => {
- if (reg.waiting && navigator.serviceWorker.controller) {
- setUpdateReady(true);
- }
-
- reg.addEventListener("updatefound", () => {
- const installing = reg.installing;
-
- installing?.addEventListener("statechange", () => {
- if (cancelled) return;
- if (
- installing.state == "installed" &&
- navigator.serviceWorker.controller
- ) {
- setUpdateReady(true);
- }
- });
- });
- };
-
navigator.serviceWorker
.register(PwaServiceWorkerPath, {
scope: "/",
@@ -73,7 +49,6 @@ export default function ServiceWorkerRegistrar() {
.then((reg) => {
if (cancelled) return;
registration.current = reg;
- watch(reg);
interval = setInterval(() => reg.update().catch(() => {}), UpdateCheckMs);
})
.catch((error) => {
@@ -160,57 +135,5 @@ export default function ServiceWorkerRegistrar() {
};
}, []);
- useEffect(() => {
- if (!reloading) return;
-
- const onControllerChange = () => window.location.reload();
- navigator.serviceWorker?.addEventListener("controllerchange", onControllerChange);
- registration.current?.waiting?.postMessage({ type: "SKIP_WAITING" });
- const fallback = setTimeout(() => window.location.reload(), 1500);
-
- return () => {
- clearTimeout(fallback);
- navigator.serviceWorker?.removeEventListener("controllerchange", onControllerChange);
- };
- }, [reloading]);
-
- if (!update_ready) return null;
-
- return (
-
-
- A new version is ready
-
-
- Reload to use it. Your conversations are saved.
-
-
-
- }
- onClick={() => setReloading(true)}
- >
- Reload
-
-
-
- );
+ return null;
}