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 + } +}