Skip to content

Commit 3772029

Browse files
Add version skew detection and refresh prompt
Why: When deploying new code to production, users with stale cached client code may experience API compatibility issues, unexpected behavior, or see bugs that have been fixed. This implements automatic detection of client/server version mismatches and prompts users to refresh. The implementation follows the pattern from https://makerkit.dev/blog/tutorials/force-update-nextjs and is adapted to our codebase structure using existing patterns: - API route: apps/next/pages/api/version.ts returns Git SHA - Hook: packages/app/utils/useVersionUpdater.ts polls for changes - Component: packages/app/components/VersionUpdater.tsx (web Dialog) - Native no-op: packages/app/components/VersionUpdater.native.tsx - Uses Tamagui Dialog pattern from SendpotRiskDialog.tsx - Uses React Query pattern from useOFACGeoBlock.ts - Uses .native.tsx tree-shaking pattern from CustomToast.tsx Web-only: Version skew detection is only needed for web deployments since native apps are updated through app stores. The .native.tsx file exports a no-op that returns null, keeping native bundle size minimal. Vercel automatically provides VERCEL_GIT_COMMIT_SHA in deployments, so no additional configuration is needed for production. The version check runs every 120 seconds by default (configurable via NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS). Test plan: - Start dev server: yarn web - Navigate to http://localhost:3000 - Verify no errors in console - Check /api/version returns a git hash - Component renders but dialog is hidden (no version change) - To test dialog: modify module-level version variable in useVersionUpdater.ts to simulate a version change - Native: verify VersionUpdater.native.tsx is used (returns null)
1 parent 13bb0b3 commit 3772029

File tree

6 files changed

+239
-0
lines changed

6 files changed

+239
-0
lines changed

apps/next/pages/api/version.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next'
2+
3+
/**
4+
* Version endpoint for detecting client/server version skew.
5+
* Returns the current Git commit SHA from the deployment.
6+
*
7+
* This endpoint is used by the VersionUpdater component to check if
8+
* the client needs to refresh to get the latest code.
9+
*
10+
* Vercel automatically provides VERCEL_GIT_COMMIT_SHA in deployments.
11+
* For other environments, provide GIT_HASH or similar.
12+
*/
13+
14+
const KNOWN_GIT_ENV_VARS = ['VERCEL_GIT_COMMIT_SHA', 'CF_PAGES_COMMIT_SHA', 'GIT_HASH']
15+
16+
async function getGitHash(): Promise<string> {
17+
// Check known environment variables first
18+
for (const envVar of KNOWN_GIT_ENV_VARS) {
19+
if (process.env[envVar]) {
20+
return process.env[envVar] as string
21+
}
22+
}
23+
24+
// Fallback to git command in development
25+
if (process.env.NODE_ENV === 'development') {
26+
try {
27+
const { execSync } = await import('node:child_process')
28+
return execSync('git log --pretty=format:"%h" -n1').toString().trim()
29+
} catch (error) {
30+
console.warn('[WARN] Could not get git hash from command:', error)
31+
return 'dev'
32+
}
33+
}
34+
35+
console.warn(
36+
'[WARN] Could not find git hash. Provide VERCEL_GIT_COMMIT_SHA or GIT_HASH environment variable.'
37+
)
38+
return 'unknown'
39+
}
40+
41+
export default async function handler(_req: NextApiRequest, res: NextApiResponse<string>) {
42+
const currentGitHash = await getGitHash()
43+
44+
// Set cache headers to prevent caching
45+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
46+
res.setHeader('Pragma', 'no-cache')
47+
res.setHeader('Expires', '0')
48+
49+
res.status(200).send(currentGitHash)
50+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Native no-op version of VersionUpdater.
3+
* Version skew detection is only needed for web deployments.
4+
* Native apps are updated through app stores, not hot reloads.
5+
*/
6+
export function VersionUpdater() {
7+
return null
8+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Button, Dialog, H2, Paragraph, YStack } from '@my/ui'
2+
import { RefreshCw } from '@tamagui/lucide-icons'
3+
import { useVersionUpdater } from 'app/utils/useVersionUpdater'
4+
import { useEffect, useState } from 'react'
5+
import { useTranslation } from 'react-i18next'
6+
7+
/**
8+
* Component that checks for version updates and prompts users to refresh.
9+
*
10+
* Web-only: This component polls the /api/version endpoint to detect when
11+
* a new version has been deployed. When a mismatch is detected, it shows
12+
* a dialog prompting the user to refresh the application.
13+
*
14+
* For native, use VersionUpdater.native.tsx which returns null.
15+
*
16+
* Based on the pattern from packages/app/features/sendpot/SendpotRiskDialog.tsx
17+
*
18+
* @param props Configuration options
19+
*/
20+
export function VersionUpdater(props: { intervalTimeInSeconds?: number }) {
21+
const { data } = useVersionUpdater(props)
22+
const [dismissed, setDismissed] = useState(false)
23+
const [isOpen, setIsOpen] = useState(false)
24+
const { t } = useTranslation('common')
25+
26+
useEffect(() => {
27+
setIsOpen(data?.didChange ?? false)
28+
}, [data?.didChange])
29+
30+
const handleRefresh = () => {
31+
window.location.reload()
32+
}
33+
34+
const handleDismiss = () => {
35+
setIsOpen(false)
36+
setDismissed(true)
37+
}
38+
39+
// Don't render if no version change detected or user dismissed
40+
if (!data?.didChange || dismissed) {
41+
return null
42+
}
43+
44+
return (
45+
<Dialog open={isOpen} onOpenChange={handleDismiss}>
46+
<Dialog.Portal>
47+
<Dialog.Overlay />
48+
<Dialog.Content
49+
key="version-updater-dialog"
50+
gap="$4"
51+
maxWidth="90%"
52+
$gtMd={{ maxWidth: '40%' }}
53+
>
54+
<YStack gap="$4" testID="versionUpdaterDialog">
55+
<H2>{t('versionUpdater.title')}</H2>
56+
<Paragraph>{t('versionUpdater.description')}</Paragraph>
57+
<YStack gap="$2" justifyContent="flex-end" $gtSm={{ flexDirection: 'row' }}>
58+
<Dialog.Close asChild>
59+
<Button theme="gray" br="$2" flex={1} $gtSm={{ flex: 0 }}>
60+
<Button.Text>{t('versionUpdater.actions.dismiss')}</Button.Text>
61+
</Button>
62+
</Dialog.Close>
63+
<Button
64+
testID="versionUpdaterRefreshButton"
65+
theme="green"
66+
onPress={handleRefresh}
67+
br="$2"
68+
flex={1}
69+
$gtSm={{ flex: 0 }}
70+
>
71+
<RefreshCw size={16} />
72+
<Button.Text color="$color0" $theme-light={{ color: '$color12' }}>
73+
{t('versionUpdater.actions.refresh')}
74+
</Button.Text>
75+
</Button>
76+
</YStack>
77+
</YStack>
78+
</Dialog.Content>
79+
</Dialog.Portal>
80+
</Dialog>
81+
)
82+
}

packages/app/i18n/resources/common/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@
1010
"applied": "Referral code applied",
1111
"cta": "Got a referral? Enter the code to get rewards!",
1212
"referredBy": "Referred by"
13+
},
14+
"versionUpdater": {
15+
"title": "New Version Available",
16+
"description": "A new version of the app is available. Please refresh to get the latest features and improvements.",
17+
"actions": {
18+
"refresh": "Refresh Now",
19+
"dismiss": "Dismiss"
20+
}
1321
}
1422
}

packages/app/provider/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Session } from '@supabase/supabase-js'
22
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
33
import type { i18n } from 'i18next'
44
import { Concerns } from 'app/concerns'
5+
import { VersionUpdater } from 'app/components/VersionUpdater'
56
import { getI18n } from 'app/i18n'
67
import ScrollDirectionProvider from 'app/provider/scroll/ScrollDirectionProvider'
78
import type React from 'react'
@@ -44,6 +45,7 @@ export function Provider({
4445
<ShimmerProvider duration={2000}>
4546
<Providers>
4647
<Concerns>{children}</Concerns>
48+
<VersionUpdater />
4749
{process.env.NEXT_PUBLIC_REACT_QUERY_DEV_TOOLS && <ReactQueryDevtools />}
4850
</Providers>
4951
</ShimmerProvider>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
3+
/**
4+
* Current version of the app that is running.
5+
* This is set on first fetch and compared on subsequent fetches.
6+
*/
7+
let version: string | null = null
8+
9+
/**
10+
* Default interval time in seconds to check for new version.
11+
* By default, it is set to 120 seconds (2 minutes).
12+
*/
13+
const DEFAULT_REFETCH_INTERVAL = 120
14+
15+
/**
16+
* Environment variable to override the default refetch interval.
17+
* Can be set via NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS.
18+
*/
19+
const VERSION_UPDATER_REFETCH_INTERVAL_SECONDS =
20+
process.env.NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS
21+
22+
interface VersionUpdaterResult {
23+
currentVersion: string
24+
oldVersion: string | null
25+
didChange: boolean
26+
}
27+
28+
interface UseVersionUpdaterProps {
29+
/**
30+
* Optional override for the refetch interval in seconds.
31+
* If not provided, uses the environment variable or default (120s).
32+
*/
33+
intervalTimeInSeconds?: number
34+
/**
35+
* Whether to enable version checking.
36+
* Defaults to true. Set to false to disable in development or testing.
37+
*/
38+
enabled?: boolean
39+
}
40+
41+
/**
42+
* Hook to check for version changes between client and server.
43+
*
44+
* This hook polls the /api/version endpoint at regular intervals
45+
* to detect when a new version has been deployed. When a version
46+
* mismatch is detected, it returns didChange: true.
47+
*
48+
* Example from packages/app/utils/useOFACGeoBlock.ts
49+
*
50+
* @param props Configuration options
51+
* @returns Query result with version information
52+
*/
53+
export function useVersionUpdater(props: UseVersionUpdaterProps = {}) {
54+
const { intervalTimeInSeconds, enabled = true } = props
55+
56+
const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS
57+
? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS)
58+
: DEFAULT_REFETCH_INTERVAL
59+
60+
const refetchInterval = (intervalTimeInSeconds ?? interval) * 1000
61+
62+
// Start fetching new version after half of the interval time
63+
const staleTime = refetchInterval / 2
64+
65+
return useQuery<VersionUpdaterResult>({
66+
queryKey: ['version-updater'],
67+
staleTime,
68+
gcTime: refetchInterval,
69+
refetchIntervalInBackground: true,
70+
refetchInterval,
71+
enabled,
72+
queryFn: async () => {
73+
const response = await fetch('/api/version')
74+
const currentVersion = await response.text()
75+
76+
const oldVersion = version
77+
version = currentVersion
78+
79+
// Only mark as changed if we had a previous version and it differs
80+
const didChange = oldVersion !== null && currentVersion !== oldVersion
81+
82+
return {
83+
currentVersion,
84+
oldVersion,
85+
didChange,
86+
}
87+
},
88+
})
89+
}

0 commit comments

Comments
 (0)