diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx
index a43875de8..2588aa29c 100644
--- a/src/app/ClientProviders.tsx
+++ b/src/app/ClientProviders.tsx
@@ -9,6 +9,7 @@
import { ConsoleGreeting } from '@/components/Global/ConsoleGreeting'
import RainCooldownIntroModal from '@/components/Global/RainCooldown/IntroModal'
import StaleCardApprovalReEnableModal from '@/components/Global/StaleCardApproval/ReEnableModal'
+import StaleDeploymentReload from '@/components/Global/StaleDeploymentReload'
import BadgeEarnToast from '@/components/Badges/BadgeEarnToast'
import { AppLockGate } from '@/components/Global/AppLock'
import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker'
@@ -61,6 +62,11 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
{/* Non-intrusive "badge unlocked" toast on /home (TASK-19791).
Global so it surfaces wherever the user lands after earning. */}
+ {/* Mounted inside the providers (not called in this
+ component's body like useOtaUpdates) because it
+ reads the query client, redux and loading-state
+ context to know when a reload is safe. */}
+
{HarnessBootstrap && (
diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts
new file mode 100644
index 000000000..1b0dafb31
--- /dev/null
+++ b/src/app/api/version/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server'
+
+/**
+ * The commit of the deployment serving this request.
+ *
+ * NEXT_PUBLIC_GIT_COMMIT_HASH is inlined at build time (next.config.js), so a
+ * client bundle carries the hash of the deployment it was built from while this
+ * route — always executed by whichever deployment is live — returns the current
+ * one. The mismatch is what useStaleDeploymentReload keys off.
+ *
+ * force-dynamic + no-store on purpose: a cached response would report a stale
+ * deployment as current and defeat the whole check. Next already emits its own
+ * no-store for dynamic routes and wins on the wire; the explicit header is the
+ * declaration of intent and the fallback if this route ever stops being dynamic.
+ */
+export const dynamic = 'force-dynamic'
+
+export async function GET() {
+ return NextResponse.json(
+ { commit: process.env.NEXT_PUBLIC_GIT_COMMIT_HASH ?? 'unknown' },
+ { headers: { 'Cache-Control': 'no-store, max-age=0' } }
+ )
+}
diff --git a/src/components/Global/StaleDeploymentReload/index.tsx b/src/components/Global/StaleDeploymentReload/index.tsx
new file mode 100644
index 000000000..e915d684e
--- /dev/null
+++ b/src/components/Global/StaleDeploymentReload/index.tsx
@@ -0,0 +1,13 @@
+'use client'
+
+import { useStaleDeploymentReload } from '@/hooks/useStaleDeploymentReload'
+
+/**
+ * Renders nothing. Exists so the hook runs inside the provider tree — it reads
+ * the query client, the redux zerodev slice and the loading-state context, none
+ * of which are available in ClientProviders' own body.
+ */
+export default function StaleDeploymentReload() {
+ useStaleDeploymentReload()
+ return null
+}
diff --git a/src/constants/cache.consts.ts b/src/constants/cache.consts.ts
index 7250952a9..c28a451d1 100644
--- a/src/constants/cache.consts.ts
+++ b/src/constants/cache.consts.ts
@@ -7,6 +7,10 @@ const CACHE_NAMES = {
USER_API: 'user-api',
TRANSACTIONS: 'transactions-api',
KYC_MERCHANT: 'kyc-merchant-api',
+ PAGES: 'pages',
+ PAGES_RSC: 'pages-rsc',
+ PAGES_RSC_PREFETCH: 'pages-rsc-prefetch',
+ OTHERS: 'others',
} as const
/**
@@ -18,3 +22,24 @@ export const USER_DATA_CACHE_PATTERNS = [
CACHE_NAMES.TRANSACTIONS,
CACHE_NAMES.KYC_MERCHANT,
] as const
+
+/**
+ * Serwist's `defaultCache` document caches (see src/app/sw.ts, which spreads
+ * `...defaultCache` from @serwist/next/worker).
+ *
+ * A cached `Response` keeps the headers it was stored with, so a document
+ * served back out of these caches replays that deployment's
+ * Content-Security-Policy-Report-Only — which is how weeks-old policies kept
+ * reporting violations long after the allow-list was fixed. Purge them before
+ * reloading onto a new deployment, or the reload can be answered from cache.
+ *
+ * Note `others` is the one that actually matches browser navigations: the
+ * `pages` rule keys off a Content-Type request header that navigations don't
+ * send, so it is effectively dead. Both are listed rather than relying on that.
+ */
+export const DOCUMENT_CACHE_PATTERNS = [
+ CACHE_NAMES.PAGES,
+ CACHE_NAMES.PAGES_RSC,
+ CACHE_NAMES.PAGES_RSC_PREFETCH,
+ CACHE_NAMES.OTHERS,
+] as const
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index a7218c146..2a5f3103c 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -25,6 +25,7 @@ import { createContext, type ReactNode, useContext, useState, useEffect, useMemo
import { captureException, setUser as setSentryUser } from '@sentry/nextjs'
// import { PUBLIC_ROUTES_REGEX } from '@/constants/routes'
import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts'
+import { purgeCaches } from '@/utils/cache.utils'
import { clearStepUpToken } from '@/services/step-up'
interface AuthContextType {
@@ -225,18 +226,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
dispatch(zerodevActions.resetZeroDevState())
// clear service worker caches (non-fatal if it fails)
- if ('caches' in window) {
- try {
- const cacheNames = await caches.keys()
- await Promise.all(
- cacheNames
- .filter((name) => USER_DATA_CACHE_PATTERNS.some((pattern) => name.includes(pattern)))
- .map((name) => caches.delete(name))
- )
- } catch (e) {
- console.warn('failed to clear caches on logout:', e)
- }
- }
+ await purgeCaches(USER_DATA_CACHE_PATTERNS)
// clear session flags
try {
diff --git a/src/hooks/__tests__/useStaleDeploymentReload.test.tsx b/src/hooks/__tests__/useStaleDeploymentReload.test.tsx
new file mode 100644
index 000000000..8eedd848a
--- /dev/null
+++ b/src/hooks/__tests__/useStaleDeploymentReload.test.tsx
@@ -0,0 +1,275 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import React from 'react'
+
+import { loadingStateContext } from '@/context/loadingStates.context'
+import { useStaleDeploymentReload } from '@/hooks/useStaleDeploymentReload'
+
+const BUILD_COMMIT = 'aaaaaaa'
+const NEW_COMMIT = 'bbbbbbb'
+
+let mockPathname = '/home'
+let mockPendingCount = 0
+let mockIsSendingUserOp = false
+
+const mockPurgeCaches = jest.fn().mockResolvedValue(undefined)
+const mockIsStandalonePwa = jest.fn().mockReturnValue(false)
+const mockIsCapacitor = jest.fn().mockReturnValue(false)
+
+jest.mock('next/navigation', () => ({
+ usePathname: () => mockPathname,
+}))
+jest.mock('@/hooks/wallet/usePendingTransactions', () => ({
+ usePendingTransactions: () => ({
+ hasPendingTransactions: mockPendingCount > 0,
+ pendingCount: mockPendingCount,
+ }),
+}))
+jest.mock('@/redux/hooks', () => ({
+ useZerodevStore: () => ({ isSendingUserOp: mockIsSendingUserOp }),
+}))
+jest.mock('@/utils/capacitor', () => ({
+ isCapacitor: () => mockIsCapacitor(),
+}))
+jest.mock('@/utils/cache.utils', () => ({
+ purgeCaches: (...args: unknown[]) => mockPurgeCaches(...args),
+ isStandalonePwa: () => mockIsStandalonePwa(),
+}))
+
+const mockReload = jest.fn()
+const mockReplace = jest.fn()
+
+function serveCommit(commit: string, ok = true) {
+ global.fetch = jest.fn().mockResolvedValue({
+ ok,
+ json: async () => ({ commit }),
+ }) as unknown as typeof fetch
+}
+
+function renderWithLoading(isLoading = false) {
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(
+ loadingStateContext.Provider,
+ {
+ value: {
+ loadingState: isLoading ? 'Executing transaction' : 'Idle',
+ setLoadingState: () => {},
+ isLoading,
+ },
+ },
+ children
+ )
+ return renderHook(() => useStaleDeploymentReload(), { wrapper })
+}
+
+beforeAll(() => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: 'https://peanut.me/home', reload: mockReload, replace: mockReplace },
+ })
+})
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ sessionStorage.clear()
+ process.env.NEXT_PUBLIC_GIT_COMMIT_HASH = BUILD_COMMIT
+ mockPathname = '/home'
+ mockPendingCount = 0
+ mockIsSendingUserOp = false
+ mockIsStandalonePwa.mockReturnValue(false)
+ mockIsCapacitor.mockReturnValue(false)
+ mockPurgeCaches.mockResolvedValue(undefined)
+})
+
+describe('useStaleDeploymentReload', () => {
+ it('does not reload when the deployed commit matches the bundle', async () => {
+ serveCommit(BUILD_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('reloads when the deployed commit differs', async () => {
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalledTimes(1))
+ })
+
+ it('purges the document caches before reloading', async () => {
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalled())
+ expect(mockPurgeCaches).toHaveBeenCalledWith(
+ expect.arrayContaining(['pages', 'pages-rsc', 'pages-rsc-prefetch', 'others'])
+ )
+ })
+
+ it('uses location.replace in a standalone PWA', async () => {
+ mockIsStandalonePwa.mockReturnValue(true)
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('https://peanut.me/home'))
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('re-checks on visibility change', async () => {
+ serveCommit(BUILD_COMMIT)
+ renderWithLoading()
+ await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1))
+
+ // the mount check just ran, so the throttle has to be stepped past
+ jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 10 * 60_000)
+ serveCommit(NEW_COMMIT)
+ document.dispatchEvent(new Event('visibilitychange'))
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalledTimes(1))
+ jest.spyOn(Date, 'now').mockRestore()
+ })
+
+ it('polls a tab that is never backgrounded or navigated', async () => {
+ jest.useFakeTimers()
+ try {
+ serveCommit(BUILD_COMMIT)
+ renderWithLoading()
+ await jest.advanceTimersByTimeAsync(0)
+ expect(global.fetch).toHaveBeenCalledTimes(1)
+
+ serveCommit(NEW_COMMIT)
+ await jest.advanceTimersByTimeAsync(30 * 60_000)
+
+ expect(mockReload).toHaveBeenCalledTimes(1)
+ } finally {
+ jest.useRealTimers()
+ }
+ })
+
+ it('is inert on native builds', async () => {
+ mockIsCapacitor.mockReturnValue(true)
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(global.fetch).not.toHaveBeenCalled()
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ describe('safety gate', () => {
+ it('holds off while a balance-decreasing mutation is pending', async () => {
+ mockPendingCount = 1
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('holds off while a user op is in flight', async () => {
+ mockIsSendingUserOp = true
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('holds off while the app reports a loading state', async () => {
+ serveCommit(NEW_COMMIT)
+ renderWithLoading(true)
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it.each(['/card', '/withdraw/crypto', '/setup', '/add-money/crypto', '/en/card'])(
+ 'holds off on %s',
+ async (pathname) => {
+ mockPathname = pathname
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ }
+ )
+
+ it('reloads once a pending transaction settles', async () => {
+ mockPendingCount = 1
+ serveCommit(NEW_COMMIT)
+ const { rerender } = renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+
+ mockPendingCount = 0
+ rerender()
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalledTimes(1))
+ })
+
+ it('reloads once the user navigates off an unsafe route', async () => {
+ mockPathname = '/card'
+ serveCommit(NEW_COMMIT)
+ const { rerender } = renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+
+ mockPathname = '/home'
+ rerender()
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalledTimes(1))
+ })
+ })
+
+ describe('loop guard', () => {
+ it('stands down if a reload was already attempted and the mismatch persists', async () => {
+ sessionStorage.setItem('peanut-stale-deploy-attempted', '1')
+ serveCommit(NEW_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('clears the attempt latch once the bundle is current again', async () => {
+ sessionStorage.setItem('peanut-stale-deploy-attempted', '1')
+ serveCommit(BUILD_COMMIT)
+ renderWithLoading()
+
+ await waitFor(() => expect(sessionStorage.getItem('peanut-stale-deploy-attempted')).toBeNull())
+ })
+
+ it('reloads at most once per mount', async () => {
+ serveCommit(NEW_COMMIT)
+ const { rerender } = renderWithLoading()
+
+ await waitFor(() => expect(mockReload).toHaveBeenCalledTimes(1))
+
+ mockPathname = '/profile'
+ rerender()
+ mockPathname = '/history'
+ rerender()
+
+ expect(mockReload).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('ignores a non-ok version response', async () => {
+ serveCommit(NEW_COMMIT, false)
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+
+ it('survives an unreachable version endpoint', async () => {
+ global.fetch = jest.fn().mockRejectedValue(new Error('offline')) as unknown as typeof fetch
+ renderWithLoading()
+
+ await waitFor(() => expect(global.fetch).toHaveBeenCalled())
+ expect(mockReload).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/hooks/useStaleDeploymentReload.ts b/src/hooks/useStaleDeploymentReload.ts
new file mode 100644
index 000000000..d89324bae
--- /dev/null
+++ b/src/hooks/useStaleDeploymentReload.ts
@@ -0,0 +1,177 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+import { useCallback, useContext, useEffect, useRef } from 'react'
+
+import { DOCUMENT_CACHE_PATTERNS } from '@/constants/cache.consts'
+import { loadingStateContext } from '@/context/loadingStates.context'
+import { usePendingTransactions } from '@/hooks/wallet/usePendingTransactions'
+import { useZerodevStore } from '@/redux/hooks'
+import { isCapacitor } from '@/utils/capacitor'
+import { isStandalonePwa, purgeCaches } from '@/utils/cache.utils'
+
+/**
+ * Reloads a document left running on a superseded deployment.
+ *
+ * Nothing else in the app does this. The service worker's `controllerchange`
+ * reload is skipped in standalone PWA (an Android standalone session can bounce
+ * out to Chrome — see the sw-registration script in layout.tsx), App Router
+ * navigations fetch RSC payloads rather than documents, and chunk-error
+ * recovery only fires once an asset actually 404s. So a loaded document can
+ * outlive arbitrarily many deploys, keeping its original JS, its API
+ * assumptions, and its response headers. That is how weeks-old
+ * Content-Security-Policy-Report-Only headers kept reporting violations against
+ * an allow-list that had already been fixed.
+ *
+ * Reload timing is deliberately conservative: a forced reload mid-flow can
+ * destroy state that only lives in component memory (a Sumsub WebSDK session,
+ * a half-filled card application) or leave the user unsure whether a payment
+ * went through. So staleness is latched on detection and acted on only at a
+ * safe boundary — tab re-focus or a route change — with no money flow running.
+ */
+
+const VERSION_ENDPOINT = '/api/version'
+
+// Read inside a function rather than at module scope: Next inlines
+// NEXT_PUBLIC_* wherever it appears, so this is still a build-time constant in
+// the bundle, but tests can set it without a second module registry.
+const buildCommit = () => process.env.NEXT_PUBLIC_GIT_COMMIT_HASH
+
+const CHECK_THROTTLE_MS = 5 * 60_000
+const RELOAD_GUARD_MS = 5 * 60_000
+
+// A tab that is never backgrounded and never navigated would otherwise only
+// ever check once, at mount — and that is precisely the shape of the sessions
+// that stayed on a superseded deployment for days. One small request per open
+// tab per half hour is the cost of catching them.
+const POLL_INTERVAL_MS = 30 * 60_000
+
+const RELOAD_AT_KEY = 'peanut-stale-deploy-reload-at'
+const RELOAD_ATTEMPTED_KEY = 'peanut-stale-deploy-attempted'
+
+/**
+ * Routes whose in-progress state lives in component memory and cannot survive a
+ * reload: the Sumsub WebSDK session and the card application's pending terms /
+ * country confirmation, the withdraw legs, passkey registration, and the onramp
+ * deposit flows. Matched per path segment so locale-prefixed paths (`/en/card`)
+ * are covered too.
+ */
+const RELOAD_UNSAFE_SEGMENTS = new Set(['card', 'withdraw', 'setup', 'add-money', 'kyc'])
+
+function hasUnsafeSegment(pathname: string): boolean {
+ return pathname.split('/').some((segment) => RELOAD_UNSAFE_SEGMENTS.has(segment))
+}
+
+function readSessionFlag(key: string): string | null {
+ try {
+ return sessionStorage.getItem(key)
+ } catch {
+ return null
+ }
+}
+
+export function useStaleDeploymentReload() {
+ const pathname = usePathname()
+ const { hasPendingTransactions } = usePendingTransactions()
+ const { isSendingUserOp } = useZerodevStore()
+ const { isLoading } = useContext(loadingStateContext)
+
+ const isStaleRef = useRef(false)
+ const isDisabledRef = useRef(false)
+ const lastCheckedAtRef = useRef(0)
+
+ // Event listeners are registered once; without this the closure would keep
+ // reading the gate values from its first render.
+ const isSafeRef = useRef(true)
+ isSafeRef.current = !hasPendingTransactions && !isSendingUserOp && !isLoading && !hasUnsafeSegment(pathname)
+
+ const reloadIfStaleAndSafe = useCallback(() => {
+ if (isDisabledRef.current || !isStaleRef.current || !isSafeRef.current) return
+
+ // A reload that does not clear the mismatch would otherwise repeat
+ // forever. One attempt per session; if we come back still stale, the
+ // reload is not the fix and this hook stands down.
+ if (readSessionFlag(RELOAD_ATTEMPTED_KEY)) {
+ isDisabledRef.current = true
+ return
+ }
+ const lastReloadAt = Number(readSessionFlag(RELOAD_AT_KEY) || 0)
+ if (Date.now() - lastReloadAt < RELOAD_GUARD_MS) return
+
+ try {
+ sessionStorage.setItem(RELOAD_AT_KEY, String(Date.now()))
+ sessionStorage.setItem(RELOAD_ATTEMPTED_KEY, '1')
+ } catch {
+ // no sessionStorage -> no loop protection -> don't auto-reload
+ return
+ }
+
+ isDisabledRef.current = true
+ void purgeCaches(DOCUMENT_CACHE_PATTERNS).then(() => {
+ // replace() rather than reload() in standalone: it leaves no history
+ // entry, which is the form least likely to bounce an Android PWA
+ // session out to the browser.
+ if (isStandalonePwa()) window.location.replace(window.location.href)
+ else window.location.reload()
+ })
+ }, [])
+
+ const checkVersion = useCallback(async () => {
+ if (isDisabledRef.current || isStaleRef.current || !buildCommit()) return
+ if (Date.now() - lastCheckedAtRef.current < CHECK_THROTTLE_MS) return
+ lastCheckedAtRef.current = Date.now()
+
+ try {
+ const response = await fetch(VERSION_ENDPOINT, { cache: 'no-store' })
+ if (!response.ok) return
+ const { commit } = (await response.json()) as { commit?: string }
+ if (!commit || commit === 'unknown') return
+
+ if (commit === buildCommit()) {
+ // Current again — clear the one-attempt latch so a later deploy
+ // can still trigger a reload in this session.
+ try {
+ sessionStorage.removeItem(RELOAD_ATTEMPTED_KEY)
+ } catch {}
+ return
+ }
+ isStaleRef.current = true
+ } catch {
+ // offline or the route is unreachable -> try again next re-focus
+ }
+ }, [])
+
+ useEffect(() => {
+ // Native builds serve local files and update through Capgo OTA, so
+ // there is no deployment to be stale against.
+ if (isCapacitor()) return
+
+ const checkNow = () => {
+ void checkVersion().then(reloadIfStaleAndSafe)
+ }
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') checkNow()
+ }
+ const onPoll = () => {
+ if (document.visibilityState === 'visible') checkNow()
+ }
+
+ // Worth checking even on a fresh mount: a document served back out of
+ // the service worker's NetworkFirst cache is already stale on arrival.
+ checkNow()
+ document.addEventListener('visibilitychange', onVisible)
+ const pollId = setInterval(onPoll, POLL_INTERVAL_MS)
+ return () => {
+ document.removeEventListener('visibilitychange', onVisible)
+ clearInterval(pollId)
+ }
+ }, [checkVersion, reloadIfStaleAndSafe])
+
+ // The other safe boundaries: a route change, or a money flow finishing and
+ // reopening the gate. No fetch here — this only acts on staleness a
+ // previous check already latched.
+ useEffect(() => {
+ if (isCapacitor()) return
+ reloadIfStaleAndSafe()
+ }, [pathname, hasPendingTransactions, isSendingUserOp, isLoading, reloadIfStaleAndSafe])
+}
diff --git a/src/utils/cache.utils.ts b/src/utils/cache.utils.ts
new file mode 100644
index 000000000..feb2e0e35
--- /dev/null
+++ b/src/utils/cache.utils.ts
@@ -0,0 +1,45 @@
+/**
+ * Cache Storage helpers shared by logout (user-scoped purge) and the
+ * stale-deployment reload (document-scoped purge).
+ */
+
+/**
+ * Deletes every Cache Storage entry whose name contains one of `patterns`.
+ * Substring matching because Serwist prefixes and suffixes its cache names
+ * (`serwist--`).
+ *
+ * Never throws: a failed purge must not break logout or block a reload.
+ */
+export async function purgeCaches(patterns: readonly string[]): Promise {
+ if (typeof window === 'undefined' || !('caches' in window)) return
+ try {
+ const cacheNames = await caches.keys()
+ await Promise.all(
+ cacheNames
+ .filter((name) => patterns.some((pattern) => name.includes(pattern)))
+ .map((name) => caches.delete(name))
+ )
+ } catch (e) {
+ console.warn('failed to purge caches:', e)
+ }
+}
+
+/**
+ * True when running as an installed PWA rather than a browser tab.
+ *
+ * Load-bearing for reload decisions: `window.location.reload()` in an Android
+ * standalone session can bounce the user out to Chrome (see the sw-registration
+ * script in layout.tsx), so callers navigate differently here.
+ */
+export function isStandalonePwa(): boolean {
+ if (typeof window === 'undefined') return false
+ try {
+ return (
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (navigator as Navigator & { standalone?: boolean }).standalone === true
+ )
+ } catch {
+ // matchMedia unavailable -> assume not standalone
+ return false
+ }
+}