feat(fase-14): PWA auto-update toast (14.1)
Migrate from `registerSW({ immediate: true })` to `useRegisterSW` from
`virtual:pwa-register/svelte`, mirror its `needRefresh` / `offlineReady`
stores into stable layout-local writables, and render a new
`UpdateToast` pill (bottom-center) that surfaces:
- "New version available" + a "Reload" button that calls
`updateServiceWorker(true)`
- "Ready to use offline" (auto-dismiss after 4s) on first install
Adds `[pwa] …` console.info telemetry on register / need-refresh /
apply. Stand-in for the unavailable `updateViaCache: 'none'` runtime
option (RegisterSWOptions doesn't expose it in vite-plugin-pwa 0.21.2):
poll `reg.update()` every 15min, which keeps a too-aggressively-cached
SW file from pinning users on the previous build for up to 24h.
`apps/web/src/global.d.ts` declares the virtual module's surface inline
so svelte-check picks it up without a relative path into node_modules;
the same file pre-declares the Fase-14.3 `__APP_VERSION__` triplet.
PU-01 stubs the registrar via `window.__pwaRegisterStub` (the layout
checks for it before calling the real `useRegisterSW`) so the toast
flow can be driven from a Playwright test without a second build.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -164,6 +164,22 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
|
"pwa_update_available": "New version available",
|
||||||
|
"pwa_update_reload": "Reload",
|
||||||
|
"pwa_offline_ready": "Ready to use offline",
|
||||||
|
"pwa_offline_chip": "Offline",
|
||||||
|
"pwa_offline_tooltip": "No connection — changes will sync when you reconnect",
|
||||||
|
"pwa_pending_ops": "{count} pending change",
|
||||||
|
"pwa_pending_ops_plural": "{count} pending changes",
|
||||||
|
"pwa_sync_conflicts_banner": "Sync conflicts need attention",
|
||||||
|
"pwa_sync_conflicts_review": "Review",
|
||||||
|
"sync_conflicts_title": "Sync conflicts",
|
||||||
|
"sync_conflicts_empty": "Nothing pending — local and server agree.",
|
||||||
|
"sync_conflicts_discard_local": "Discard local",
|
||||||
|
"sync_conflicts_discard_remote": "Discard remote",
|
||||||
|
"sync_conflicts_back": "Back to settings",
|
||||||
|
"settings_about": "About",
|
||||||
|
"settings_about_version": "v{version} · {commit} · {date}",
|
||||||
"sync_offline": "You're offline — changes will sync when you reconnect",
|
"sync_offline": "You're offline — changes will sync when you reconnect",
|
||||||
"sync_syncing": "Syncing…",
|
"sync_syncing": "Syncing…",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
|
|||||||
@@ -164,6 +164,22 @@
|
|||||||
"edit": "Editar",
|
"edit": "Editar",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"done": "Listo",
|
"done": "Listo",
|
||||||
|
"pwa_update_available": "Nueva versión disponible",
|
||||||
|
"pwa_update_reload": "Recargar",
|
||||||
|
"pwa_offline_ready": "Lista para usar offline",
|
||||||
|
"pwa_offline_chip": "Sin conexión",
|
||||||
|
"pwa_offline_tooltip": "Sin conexión — los cambios se sincronizarán al volver",
|
||||||
|
"pwa_pending_ops": "{count} cambio pendiente",
|
||||||
|
"pwa_pending_ops_plural": "{count} cambios pendientes",
|
||||||
|
"pwa_sync_conflicts_banner": "Hay conflictos de sincronización",
|
||||||
|
"pwa_sync_conflicts_review": "Revisar",
|
||||||
|
"sync_conflicts_title": "Conflictos de sincronización",
|
||||||
|
"sync_conflicts_empty": "Nada pendiente — local y servidor coinciden.",
|
||||||
|
"sync_conflicts_discard_local": "Descartar local",
|
||||||
|
"sync_conflicts_discard_remote": "Descartar remoto",
|
||||||
|
"sync_conflicts_back": "Volver a ajustes",
|
||||||
|
"settings_about": "Acerca de",
|
||||||
|
"settings_about_version": "v{version} · {commit} · {date}",
|
||||||
"sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar",
|
"sync_offline": "Sin conexión — los cambios se sincronizarán al reconectar",
|
||||||
"sync_syncing": "Sincronizando…",
|
"sync_syncing": "Sincronizando…",
|
||||||
"undo": "Deshacer",
|
"undo": "Deshacer",
|
||||||
|
|||||||
40
apps/web/src/global.d.ts
vendored
Normal file
40
apps/web/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Ambient declarations for app-wide constants and virtual modules.
|
||||||
|
*
|
||||||
|
* - `virtual:pwa-register/svelte` is provided at build time by
|
||||||
|
* `vite-plugin-pwa`'s Svelte helper (Fase 14.1). vite-plugin-pwa ships
|
||||||
|
* `svelte.d.ts` for this but TypeScript only picks it up if the path
|
||||||
|
* is referenced; declaring the module here inlines the (small) surface
|
||||||
|
* without coupling to the package's internal layout.
|
||||||
|
*
|
||||||
|
* - `__APP_VERSION__`, `__APP_COMMIT__`, `__BUILD_TIME__` (Fase 14.3)
|
||||||
|
* are replaced literally by Vite at build time via the `define`
|
||||||
|
* option in `vite.config.ts`. They're consumed exclusively through
|
||||||
|
* `$lib/version.ts` — never inline — so tree-shaking can't drop
|
||||||
|
* the build-time substitution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare module 'virtual:pwa-register/svelte' {
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export interface RegisterSWOptions {
|
||||||
|
immediate?: boolean;
|
||||||
|
onNeedRefresh?: () => void;
|
||||||
|
onOfflineReady?: () => void;
|
||||||
|
onRegisteredSW?: (
|
||||||
|
swScriptUrl: string,
|
||||||
|
registration: ServiceWorkerRegistration | undefined
|
||||||
|
) => void;
|
||||||
|
onRegisterError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterSW(options?: RegisterSWOptions): {
|
||||||
|
needRefresh: Writable<boolean>;
|
||||||
|
offlineReady: Writable<boolean>;
|
||||||
|
updateServiceWorker: (reloadPage?: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
declare const __APP_COMMIT__: string;
|
||||||
|
declare const __BUILD_TIME__: string;
|
||||||
92
apps/web/src/lib/components/UpdateToast.svelte
Normal file
92
apps/web/src/lib/components/UpdateToast.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Fase 14.1 — PWA update + offline-ready notification.
|
||||||
|
*
|
||||||
|
* Driven by two writable<boolean> stores produced by `useRegisterSW()`
|
||||||
|
* (or its test stub). The component is purely presentational: state
|
||||||
|
* lives in the stores, behaviour (`onReload`) is injected.
|
||||||
|
*
|
||||||
|
* - When `needRefresh` flips true, a sticky pill appears with a
|
||||||
|
* "Reload" button. Clicking it calls `onReload()` which is wired
|
||||||
|
* in the root layout to `updateServiceWorker(true)`.
|
||||||
|
* - When `offlineReady` flips true (first install), a transient
|
||||||
|
* pill appears for 4s and then auto-dismisses.
|
||||||
|
*
|
||||||
|
* Both pills share the same bottom-center slot. If both fire at once
|
||||||
|
* the update toast wins (more actionable).
|
||||||
|
*/
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
|
const {
|
||||||
|
needRefresh,
|
||||||
|
offlineReady,
|
||||||
|
onReload
|
||||||
|
}: {
|
||||||
|
needRefresh: Writable<boolean>;
|
||||||
|
offlineReady: Writable<boolean>;
|
||||||
|
onReload: () => void | Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let showOfflineReady = $state(false);
|
||||||
|
let offlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const unsub = offlineReady.subscribe((v) => {
|
||||||
|
if (!v) return;
|
||||||
|
showOfflineReady = true;
|
||||||
|
console.info('[pwa] offline-ready');
|
||||||
|
if (offlineTimer) clearTimeout(offlineTimer);
|
||||||
|
offlineTimer = setTimeout(() => {
|
||||||
|
showOfflineReady = false;
|
||||||
|
offlineReady.set(false);
|
||||||
|
}, 4_000);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsub();
|
||||||
|
if (offlineTimer) clearTimeout(offlineTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleReload() {
|
||||||
|
console.info('[pwa] applying update');
|
||||||
|
await onReload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $needRefresh}
|
||||||
|
<div
|
||||||
|
data-testid="pwa-update-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-center gap-3 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
>
|
||||||
|
<span>{m.pwa_update_available()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pwa-update-reload"
|
||||||
|
onclick={handleReload}
|
||||||
|
class="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white hover:bg-white/25 dark:bg-slate-900/15 dark:text-slate-900 dark:hover:bg-slate-900/25"
|
||||||
|
>
|
||||||
|
{m.pwa_update_reload()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if showOfflineReady}
|
||||||
|
<div
|
||||||
|
data-testid="pwa-offline-ready-toast"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4 md:bottom-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm text-white shadow-[0px_20px_40px_rgba(15,23,42,0.3)] dark:bg-slate-100 dark:text-slate-900"
|
||||||
|
>
|
||||||
|
<span>{m.pwa_offline_ready()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
@@ -18,6 +19,16 @@
|
|||||||
import { initTheme } from '$lib/theme';
|
import { initTheme } from '$lib/theme';
|
||||||
import { setLanguageTag } from '$lib/paraglide/runtime';
|
import { setLanguageTag } from '$lib/paraglide/runtime';
|
||||||
import { detectLanguage } from '$lib/utils/accept-language';
|
import { detectLanguage } from '$lib/utils/accept-language';
|
||||||
|
import UpdateToast from '$lib/components/UpdateToast.svelte';
|
||||||
|
|
||||||
|
// Fase 14.1: stores driven by `useRegisterSW()` (or by a test stub
|
||||||
|
// injected via window.__pwaRegisterStub). We expose stable writable
|
||||||
|
// proxies that mirror the upstream values — this avoids needing
|
||||||
|
// runes in the (legacy-mode) root layout AND keeps the reference
|
||||||
|
// passed into <UpdateToast /> stable across the dynamic-import gap.
|
||||||
|
const needRefresh: Writable<boolean> = writable(false);
|
||||||
|
const offlineReady: Writable<boolean> = writable(false);
|
||||||
|
let pwaReload: () => void | Promise<void> = () => {};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Fase 9.1: hydrate the theme stores from localStorage and start
|
// Fase 9.1: hydrate the theme stores from localStorage and start
|
||||||
@@ -34,11 +45,69 @@
|
|||||||
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
|
(window as unknown as { __sb?: unknown }).__sb = getSupabase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fase 6.1: register the PWA service worker. `@vite-pwa/sveltekit` does
|
// Fase 14.1: register the PWA service worker via the Svelte-flavoured
|
||||||
// not auto-register — the virtual module is a no-op unless called.
|
// virtual module so we get writable stores for needRefresh /
|
||||||
void import('virtual:pwa-register').then(({ registerSW }) => {
|
// offlineReady (UpdateToast subscribes to them).
|
||||||
registerSW({ immediate: true });
|
//
|
||||||
|
// Note on `updateViaCache: 'none'`: the plan asks for this on the
|
||||||
|
// registration. `vite-plugin-pwa` 0.21.2's RegisterSWOptions doesn't
|
||||||
|
// expose it (workbox-window 7 doesn't either). Mitigated below by an
|
||||||
|
// explicit `reg.update()` poll every 15 minutes — covers the worst
|
||||||
|
// case (a stale SW file the browser cached too aggressively) without
|
||||||
|
// downgrading the plugin.
|
||||||
|
//
|
||||||
|
// The `__pwaRegisterStub` window hook lets Playwright inject a fake
|
||||||
|
// registrar without spinning up a second build (PU-01). We mirror
|
||||||
|
// the upstream stores into our stable `needRefresh` / `offlineReady`
|
||||||
|
// writables so the references handed to <UpdateToast/> never change.
|
||||||
|
const pwaUnsubs: Array<() => void> = [];
|
||||||
|
const wirePwaStores = (r: {
|
||||||
|
needRefresh: Writable<boolean>;
|
||||||
|
offlineReady: Writable<boolean>;
|
||||||
|
updateServiceWorker: (reload?: boolean) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
pwaReload = () => r.updateServiceWorker(true);
|
||||||
|
pwaUnsubs.push(r.needRefresh.subscribe((v) => needRefresh.set(v)));
|
||||||
|
pwaUnsubs.push(r.offlineReady.subscribe((v) => offlineReady.set(v)));
|
||||||
|
};
|
||||||
|
const stub = (window as unknown as {
|
||||||
|
__pwaRegisterStub?: () => {
|
||||||
|
needRefresh: Writable<boolean>;
|
||||||
|
offlineReady: Writable<boolean>;
|
||||||
|
updateServiceWorker: (reload?: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
}).__pwaRegisterStub;
|
||||||
|
if (stub) {
|
||||||
|
console.info('[pwa] registered (stub)');
|
||||||
|
wirePwaStores(stub());
|
||||||
|
} else {
|
||||||
|
void import('virtual:pwa-register/svelte').then(({ useRegisterSW }) => {
|
||||||
|
const r = useRegisterSW({
|
||||||
|
immediate: true,
|
||||||
|
onRegisteredSW(swUrl: string, reg: ServiceWorkerRegistration | undefined) {
|
||||||
|
console.info('[pwa] registered', swUrl);
|
||||||
|
// Stand-in for `updateViaCache: 'none'`: poll the SW
|
||||||
|
// for an update every 15 minutes. Without this a
|
||||||
|
// stale cached SW file could keep the user on the
|
||||||
|
// previous build for up to 24h in some browsers.
|
||||||
|
if (reg) {
|
||||||
|
setInterval(() => {
|
||||||
|
reg.update().catch(() => {
|
||||||
|
/* offline / transient */
|
||||||
});
|
});
|
||||||
|
}, 15 * 60 * 1000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRegisterError(err: unknown) {
|
||||||
|
console.info('[pwa] register error', err);
|
||||||
|
},
|
||||||
|
onNeedRefresh() {
|
||||||
|
console.info('[pwa] update available');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
wirePwaStores(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = getSupabase();
|
const supabase = getSupabase();
|
||||||
|
|
||||||
@@ -117,6 +186,7 @@
|
|||||||
collectiveUnsub();
|
collectiveUnsub();
|
||||||
void teardownFeatureSubscriptions();
|
void teardownFeatureSubscriptions();
|
||||||
clearServerAdminFlag();
|
clearServerAdminFlag();
|
||||||
|
for (const u of pwaUnsubs) u();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -209,4 +279,5 @@
|
|||||||
|
|
||||||
<ParaglideJS {i18n}>
|
<ParaglideJS {i18n}>
|
||||||
<slot />
|
<slot />
|
||||||
|
<UpdateToast {needRefresh} {offlineReady} onReload={pwaReload} />
|
||||||
</ParaglideJS>
|
</ParaglideJS>
|
||||||
|
|||||||
102
apps/web/tests/e2e/pwa-update.test.ts
Normal file
102
apps/web/tests/e2e/pwa-update.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* PU-series — Fase 14.1 PWA auto-update toast.
|
||||||
|
*
|
||||||
|
* The real Workbox `needRefresh` event is hard to drive from a Playwright
|
||||||
|
* test (you need a second build pushed to the dev server) so we stub the
|
||||||
|
* virtual:pwa-register/svelte module via `addInitScript`. The stub pretends
|
||||||
|
* a new SW was detected by setting `needRefresh = true` on the writable
|
||||||
|
* store returned by `useRegisterSW` as soon as the layout calls it.
|
||||||
|
*
|
||||||
|
* Asserts:
|
||||||
|
* - UpdateToast appears with the "new version" copy
|
||||||
|
* - the "Recargar" button is wired (we intercept window.location.reload
|
||||||
|
* because Workbox's updateServiceWorker(true) ultimately reloads)
|
||||||
|
* - the onOfflineReady toast also surfaces when its store flips true
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { USERS } from '../fixtures/users.js';
|
||||||
|
import { loginAs } from '../fixtures/login.js';
|
||||||
|
|
||||||
|
test.describe('PWA auto-update toast', () => {
|
||||||
|
test('PU-01: needRefresh = true surfaces the update toast and Recargar invokes updateSW', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Stub the virtual module BEFORE any app code runs. The layout
|
||||||
|
// imports it inside onMount(), so this init script must define
|
||||||
|
// the module on the window before that dynamic import resolves.
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
// Track whether the user clicked "Recargar".
|
||||||
|
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = false;
|
||||||
|
|
||||||
|
// Minimal Svelte-store-shaped objects: { subscribe, set, update }.
|
||||||
|
// The real store is `writable<boolean>`. The component only does
|
||||||
|
// `$needRefresh` / `$offlineReady` reads so a tiny shim suffices.
|
||||||
|
const makeStore = (initial: boolean) => {
|
||||||
|
let value = initial;
|
||||||
|
const subs = new Set<(v: boolean) => void>();
|
||||||
|
return {
|
||||||
|
subscribe(fn: (v: boolean) => void) {
|
||||||
|
subs.add(fn);
|
||||||
|
fn(value);
|
||||||
|
return () => subs.delete(fn);
|
||||||
|
},
|
||||||
|
set(v: boolean) {
|
||||||
|
value = v;
|
||||||
|
for (const s of subs) s(v);
|
||||||
|
},
|
||||||
|
update(fn: (v: boolean) => boolean) {
|
||||||
|
this.set(fn(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const needRefresh = makeStore(false);
|
||||||
|
const offlineReady = makeStore(false);
|
||||||
|
(window as unknown as { __pwaStores?: unknown }).__pwaStores = {
|
||||||
|
needRefresh,
|
||||||
|
offlineReady
|
||||||
|
};
|
||||||
|
|
||||||
|
// Intercept the dynamic import in the layout. Vite hot-modules
|
||||||
|
// resolve through a global resolver in dev — easier path is to
|
||||||
|
// patch the global module cache used by SvelteKit's runtime.
|
||||||
|
// We install a property on window that the layout checks first
|
||||||
|
// (`window.__pwaRegisterStub`); if absent, real path runs.
|
||||||
|
(window as unknown as { __pwaRegisterStub?: unknown }).__pwaRegisterStub = () => ({
|
||||||
|
needRefresh,
|
||||||
|
offlineReady,
|
||||||
|
updateServiceWorker: async () => {
|
||||||
|
(window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await loginAs(page, USERS.borja);
|
||||||
|
await page.goto('/lists');
|
||||||
|
|
||||||
|
// Wait until the layout's onMount has had a chance to wire the stub.
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => Boolean((window as unknown as { __pwaStores?: { needRefresh: { set: (v: boolean) => void } } }).__pwaStores),
|
||||||
|
null,
|
||||||
|
{ timeout: 10_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Flip the needRefresh store; the toast should mount.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const stores = (window as unknown as { __pwaStores: { needRefresh: { set: (v: boolean) => void } } })
|
||||||
|
.__pwaStores;
|
||||||
|
stores.needRefresh.set(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toast = page.getByTestId('pwa-update-toast');
|
||||||
|
await expect(toast).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Click "Recargar" — should call updateServiceWorker(true).
|
||||||
|
await page.getByTestId('pwa-update-reload').click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (window as unknown as { __pwaUpdated?: boolean }).__pwaUpdated === true,
|
||||||
|
null,
|
||||||
|
{ timeout: 3_000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user