From c319e60a8de333508330154969f3921c6e574d68 Mon Sep 17 00:00:00 2001 From: innolove-dev Date: Wed, 29 Jul 2026 09:29:09 +0100 Subject: [PATCH] fix(deploy): reload documents left running on a superseded deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A loaded document can outlive arbitrarily many deploys, keeping its original JS chunks, API assumptions and response headers. The service worker's controllerchange reload is skipped in standalone PWA (an Android standalone session can bounce out to Chrome), App Router navigations fetch RSC payloads rather than documents, and chunk-error recovery only fires once an asset 404s. 1,603 users were on weeks-old bundles — visible as CSP violation reports replaying allow-lists that had already been fixed. /api/version returns the live deployment's commit; useStaleDeploymentReload compares it against the build-inlined NEXT_PUBLIC_GIT_COMMIT_HASH on mount, on tab re-focus and on a 30-minute poll. The mount-only case is exactly the shape of the sessions that stayed stale for days, so the poll is what makes this land on the real population. 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 a user unsure whether a payment went through. Staleness is latched on detection and acted on only at a safe boundary — gated on pending balance-decreasing mutations, the zerodev user-op flag, the loading-state context, and a path deny-list. Document caches are purged before reloading: a cached Response replays the headers it was stored with, so the reload could otherwise be answered from cache with the same stale document. A one-attempt sessionStorage latch stands the hook down if a reload does not clear the mismatch, instead of looping. purgeCaches/isStandalonePwa are extracted to cache.utils and reused by logout, which had the purge inline. --- src/app/ClientProviders.tsx | 6 + src/app/api/version/route.ts | 23 ++ .../Global/StaleDeploymentReload/index.tsx | 13 + src/constants/cache.consts.ts | 25 ++ src/context/authContext.tsx | 14 +- .../useStaleDeploymentReload.test.tsx | 275 ++++++++++++++++++ src/hooks/useStaleDeploymentReload.ts | 177 +++++++++++ src/utils/cache.utils.ts | 45 +++ 8 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 src/app/api/version/route.ts create mode 100644 src/components/Global/StaleDeploymentReload/index.tsx create mode 100644 src/hooks/__tests__/useStaleDeploymentReload.test.tsx create mode 100644 src/hooks/useStaleDeploymentReload.ts create mode 100644 src/utils/cache.utils.ts diff --git a/src/app/ClientProviders.tsx b/src/app/ClientProviders.tsx index a43875de8c..2588aa29c9 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 0000000000..1b0dafb318 --- /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 0000000000..e915d684ea --- /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 7250952a9a..c28a451d15 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 a7218c1465..2a5f3103ce 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 0000000000..8eedd848a0 --- /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 0000000000..d89324baef --- /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 0000000000..feb2e0e35a --- /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 + } +}