Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app/ClientProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */}
<BadgeEarnToast />
{/* 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. */}
<StaleDeploymentReload />
{HarnessBootstrap && (
<Suspense fallback={null}>
<HarnessBootstrap />
Expand Down
23 changes: 23 additions & 0 deletions src/app/api/version/route.ts
Original file line number Diff line number Diff line change
@@ -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' } }
)
}
13 changes: 13 additions & 0 deletions src/components/Global/StaleDeploymentReload/index.tsx
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions src/constants/cache.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
14 changes: 2 additions & 12 deletions src/context/authContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
275 changes: 275 additions & 0 deletions src/hooks/__tests__/useStaleDeploymentReload.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading