Skip to content

Commit 2098b14

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 8d71da5 commit 2098b14

File tree

6 files changed

+281
-0
lines changed

6 files changed

+281
-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: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { AnimatePresence, Button, Circle, Dialog, H2, Paragraph, YStack } from '@my/ui'
2+
import { Sparkles } 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 blocking modal dialog that forces the user to refresh.
13+
*
14+
* The dialog is non-dismissible to prevent users from continuing with stale
15+
* code that could cause errors or unexpected behavior.
16+
*
17+
* For native, use VersionUpdater.native.tsx which returns null.
18+
*
19+
* Based on the pattern from packages/app/features/sendpot/SendpotRiskDialog.tsx
20+
*
21+
* @param props Configuration options
22+
*/
23+
export function VersionUpdater(props: { intervalTimeInSeconds?: number }) {
24+
const { data } = useVersionUpdater(props)
25+
const [isOpen, setIsOpen] = useState(false)
26+
const { t } = useTranslation('common')
27+
28+
useEffect(() => {
29+
setIsOpen(data?.didChange ?? false)
30+
}, [data?.didChange])
31+
32+
const handleRefresh = () => {
33+
window.location.reload()
34+
}
35+
36+
// Don't render if no version change detected
37+
if (!data?.didChange) {
38+
return null
39+
}
40+
41+
return (
42+
<AnimatePresence>
43+
{isOpen && (
44+
<Dialog open={isOpen} modal>
45+
<Dialog.Portal>
46+
<Dialog.Overlay
47+
key="version-updater-overlay"
48+
animation="100ms"
49+
opacity={0.75}
50+
enterStyle={{ opacity: 0 }}
51+
exitStyle={{ opacity: 0 }}
52+
/>
53+
<Dialog.Content
54+
key="version-updater-dialog"
55+
elevation="$9"
56+
shadowOpacity={0.4}
57+
animation="responsive"
58+
animateOnly={['opacity', 'transform']}
59+
enterStyle={{ opacity: 0, scale: 0.98, y: -10 }}
60+
exitStyle={{ opacity: 0, scale: 0.98, y: 10 }}
61+
gap="$5"
62+
maxWidth="90%"
63+
$gtMd={{ maxWidth: 420 }}
64+
padding="$6"
65+
br="$6"
66+
>
67+
<YStack gap="$5" testID="versionUpdaterDialog" ai="center">
68+
{/* Icon container with brand green background */}
69+
<Circle
70+
size={80}
71+
backgroundColor="$neon7"
72+
ai="center"
73+
jc="center"
74+
animation="200ms"
75+
enterStyle={{ scale: 0 }}
76+
scale={1}
77+
>
78+
<Sparkles size={40} color="$black" strokeWidth={2.5} />
79+
</Circle>
80+
81+
{/* Title and description */}
82+
<YStack gap="$3" ai="center">
83+
<H2 size="$8" ta="center" fontWeight="600">
84+
{t('versionUpdater.title')}
85+
</H2>
86+
<Paragraph size="$5" ta="center" color="$color11" maxWidth={340} lineHeight="$5">
87+
{t('versionUpdater.description')}
88+
</Paragraph>
89+
</YStack>
90+
91+
{/* Action button */}
92+
<Button
93+
testID="versionUpdaterRefreshButton"
94+
backgroundColor="$neon7"
95+
size="$5"
96+
onPress={handleRefresh}
97+
br="$4"
98+
w="100%"
99+
maxWidth={280}
100+
fontWeight="600"
101+
borderWidth={0}
102+
pressStyle={{ scale: 0.98, backgroundColor: '$neon7' }}
103+
hoverStyle={{ backgroundColor: '$neon6' }}
104+
focusStyle={{ outlineWidth: 0 }}
105+
animation="responsive"
106+
animateOnly={['transform', 'opacity']}
107+
>
108+
<Button.Text
109+
color="$gray1"
110+
$theme-light={{ color: '$gray12' }}
111+
fontSize="$5"
112+
fontWeight="600"
113+
>
114+
{t('versionUpdater.actions.refresh')}
115+
</Button.Text>
116+
</Button>
117+
</YStack>
118+
</Dialog.Content>
119+
</Dialog.Portal>
120+
</Dialog>
121+
)}
122+
</AnimatePresence>
123+
)
124+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,12 @@
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": "Update Available",
16+
"description": "We've released a new version with improvements and fixes. Update now to continue.",
17+
"actions": {
18+
"refresh": "Update Now"
19+
}
1320
}
1421
}

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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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 and
45+
* when the window regains focus. When a version mismatch is detected,
46+
* 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+
refetchInterval,
70+
refetchIntervalInBackground: true,
71+
refetchOnWindowFocus: true,
72+
enabled,
73+
queryFn: async () => {
74+
const response = await fetch('/api/version')
75+
const currentVersion = await response.text()
76+
77+
const oldVersion = version
78+
version = currentVersion
79+
80+
// Only mark as changed if we had a previous version and it differs
81+
const didChange = oldVersion !== null && currentVersion !== oldVersion
82+
83+
return {
84+
currentVersion,
85+
oldVersion,
86+
didChange,
87+
}
88+
},
89+
})
90+
}

0 commit comments

Comments
 (0)