Skip to content

Commit ebb564b

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 shows dialog - Uses Tamagui Dialog/Sheet pattern from SendpotRiskDialog.tsx - Uses React Query pattern from useOFACGeoBlock.ts 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
1 parent 13bb0b3 commit ebb564b

File tree

5 files changed

+259
-0
lines changed

5 files changed

+259
-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: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Button, Dialog, H2, Paragraph, Sheet, 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+
import { Platform } from 'react-native'
7+
8+
/**
9+
* Component that checks for version updates and prompts users to refresh.
10+
*
11+
* This component polls the /api/version endpoint to detect when a new
12+
* version has been deployed. When a mismatch is detected, it shows a
13+
* dialog prompting the user to refresh the application.
14+
*
15+
* Based on the pattern from packages/app/features/sendpot/SendpotRiskDialog.tsx
16+
*
17+
* @param props Configuration options
18+
*/
19+
export function VersionUpdater(props: { intervalTimeInSeconds?: number }) {
20+
const { data } = useVersionUpdater(props)
21+
const [dismissed, setDismissed] = useState(false)
22+
const [isOpen, setIsOpen] = useState(false)
23+
const { t } = useTranslation('common')
24+
25+
useEffect(() => {
26+
setIsOpen(data?.didChange ?? false)
27+
}, [data?.didChange])
28+
29+
const handleRefresh = () => {
30+
window.location.reload()
31+
}
32+
33+
const handleDismiss = () => {
34+
setIsOpen(false)
35+
setDismissed(true)
36+
}
37+
38+
// Don't render if no version change detected or user dismissed
39+
if (!data?.didChange || dismissed) {
40+
return null
41+
}
42+
43+
// Shared content component to avoid duplication
44+
const dialogContent = (
45+
<YStack gap="$4" testID="versionUpdaterDialog">
46+
<H2>{t('versionUpdater.title')}</H2>
47+
<Paragraph>{t('versionUpdater.description')}</Paragraph>
48+
<YStack gap="$2" justifyContent="flex-end" $gtSm={{ flexDirection: 'row' }}>
49+
{Platform.OS === 'web' && (
50+
<Dialog.Close asChild>
51+
<Button theme="gray" br="$2" flex={1} $gtSm={{ flex: 0 }}>
52+
<Button.Text>{t('versionUpdater.actions.dismiss')}</Button.Text>
53+
</Button>
54+
</Dialog.Close>
55+
)}
56+
<Button
57+
testID="versionUpdaterRefreshButton"
58+
theme="green"
59+
onPress={handleRefresh}
60+
br="$2"
61+
flex={1}
62+
$gtSm={{ flex: 0 }}
63+
>
64+
<RefreshCw size={16} />
65+
<Button.Text color="$color0" $theme-light={{ color: '$color12' }}>
66+
{t('versionUpdater.actions.refresh')}
67+
</Button.Text>
68+
</Button>
69+
</YStack>
70+
</YStack>
71+
)
72+
73+
// Web version using Dialog
74+
if (Platform.OS === 'web') {
75+
return (
76+
<Dialog open={isOpen} onOpenChange={handleDismiss}>
77+
<Dialog.Portal>
78+
<Dialog.Overlay />
79+
<Dialog.Content
80+
key="version-updater-dialog"
81+
gap="$4"
82+
maxWidth="90%"
83+
$gtMd={{ maxWidth: '40%' }}
84+
>
85+
{dialogContent}
86+
</Dialog.Content>
87+
</Dialog.Portal>
88+
</Dialog>
89+
)
90+
}
91+
92+
// Native version using Sheet
93+
return (
94+
<Sheet
95+
open={isOpen}
96+
onOpenChange={handleDismiss}
97+
modal
98+
dismissOnSnapToBottom
99+
dismissOnOverlayPress
100+
native
101+
snapPoints={['fit']}
102+
snapPointsMode="fit"
103+
>
104+
<Sheet.Frame key="version-updater-sheet" gap="$4" padding="$4" pb="$6">
105+
{dialogContent}
106+
</Sheet.Frame>
107+
<Sheet.Overlay />
108+
</Sheet>
109+
)
110+
}

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)