Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {createContext} from 'sanity/_createContext'

/**
* Context for tracking deployment changes via etag comparison
* @beta
*/
export interface DeploymentNotificationContextValue {
/**
* The current etag value from the deployment
*/
currentEtag: string | null

/**
* The initial etag value when the app first loaded
*/
initialEtag: string | null

/**
* Whether a new deployment is available (etag has changed)
*/
hasNewDeployment: boolean

/**
* Whether the notifier is currently checking for updates
*/
isChecking: boolean

/**
* Timestamp of the last check
*/
lastCheckedAt: number | null

/**
* Manually trigger a check for new deployment
*/
checkForDeployment: () => Promise<void>
}

/**
* @internal
*/
export const DeploymentNotificationContext =
createContext<DeploymentNotificationContextValue | null>(
'sanity/_singletons/context/deployment-notification',
null,
)
1 change: 1 addition & 0 deletions packages/sanity/src/_singletons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './context/CommentsOnboardingContext'
export * from './context/CommentsSelectedPathContext'
export * from './context/CommentsUpsellContext'
export * from './context/CopyPasteContext'
export * from './context/DeploymentNotificationContext'
export * from './context/DiffContext'
export * from './context/DocumentActionPropsContext'
export * from './context/DocumentChangeContext'
Expand Down
8 changes: 8 additions & 0 deletions packages/sanity/src/core/i18n/bundles/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,14 @@ export const studioLocaleStrings = defineLocalesResources('studio', {
/** Title for the default ordering/SortOrder if no orderings are provided and the title field is found */
'default-orderings.title': 'Sort by Title',

/** Descriptive message informing user that a new Studio deployment is available and that reloading will yield the latest changes */
'deployment-notifier.description':
'A new version of this studio has been deployed. Reload to get the latest changes.',
/** Button text to reload the studio when a new deployment is available */
'deployment-notifier.reload': 'Reload ',
/** Title for the new studio deployment notifier */
'deployment-notifier.title': 'New version available',

/** Label to show in the document footer indicating the creation date of the document */
'document-status.created': 'Created {{date}}',

Expand Down
30 changes: 17 additions & 13 deletions packages/sanity/src/core/studio/StudioProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {AuthBoundary} from './AuthBoundary'
import {ColorSchemeProvider} from './colorScheme'
import {ComlinkRouteHandler} from './components/ComlinkRouteHandler'
import {Z_OFFSET} from './constants'
import {DeploymentNotificationProvider, DeploymentNotificationToast} from './deploymentNotification'
import {MaybeEnableErrorReporting} from './MaybeEnableErrorReporting'
import {PackageVersionStatusProvider} from './packageVersionStatus/PackageVersionStatusProvider'
import {
Expand Down Expand Up @@ -74,19 +75,22 @@ export function StudioProvider({
<StudioTelemetryProvider config={config}>
<LocaleProvider>
<PackageVersionStatusProvider>
<MaybeEnableErrorReporting errorReporter={errorReporter} />
<ResourceCacheProvider>
<AppIdCacheProvider>
<ComlinkRouteHandler />
<StudioAnnouncementsProvider>
<GlobalPerspectiveProvider>
<DocumentLimitUpsellProvider>
<AssetLimitUpsellProvider>{children}</AssetLimitUpsellProvider>
</DocumentLimitUpsellProvider>
</GlobalPerspectiveProvider>
</StudioAnnouncementsProvider>
</AppIdCacheProvider>
</ResourceCacheProvider>
<DeploymentNotificationProvider>
<DeploymentNotificationToast />
<MaybeEnableErrorReporting errorReporter={errorReporter} />
<ResourceCacheProvider>
<AppIdCacheProvider>
<ComlinkRouteHandler />
<StudioAnnouncementsProvider>
<GlobalPerspectiveProvider>
<DocumentLimitUpsellProvider>
<AssetLimitUpsellProvider>{children}</AssetLimitUpsellProvider>
</DocumentLimitUpsellProvider>
</GlobalPerspectiveProvider>
</StudioAnnouncementsProvider>
</AppIdCacheProvider>
</ResourceCacheProvider>
</DeploymentNotificationProvider>
</PackageVersionStatusProvider>
</LocaleProvider>
</StudioTelemetryProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {
DeploymentNotificationContext,
type DeploymentNotificationContextValue,
} from 'sanity/_singletons'

import {fetchDeploymentEtag} from './fetchDeploymentEtag'
import {shouldCheckForDeployment} from './shouldCheckForDeployment'

const POLL_INTERVAL_MS = 30 * 1000 // check every 30 seconds
const CHECK_THROTTLE_TIME_MS = 5 * 1000 // prevent checking more often than every 5 seconds

interface DeploymentNotificationProviderProps {
children: ReactNode
}

/**
* Provider that polls for deployment changes by checking etag headers
* Only active on *.sanity.studio or *.studio.sanity.work domains
* @beta
*/
export function DeploymentNotificationProvider({
children,
}: DeploymentNotificationProviderProps): ReactNode {
const [initialEtag, setInitialEtag] = useState<string | null>(null)
const [currentEtag, setCurrentEtag] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null)
const lastCheckTimeRef = useRef<number>(0)
const isEnabled = useMemo(() => shouldCheckForDeployment(), [])

const checkForDeployment = useCallback(async () => {
if (!isEnabled) {
return
}

const now = Date.now()
const timeSinceLastCheck = now - lastCheckTimeRef.current

// Throttle checks to prevent excessive requests
if (timeSinceLastCheck < CHECK_THROTTLE_TIME_MS) {
return
}

lastCheckTimeRef.current = now
setIsChecking(true)

fetchDeploymentEtag()
.then((etag) => {
if (!etag) {
return
}
if (!initialEtag) {
setInitialEtag(etag)
}
setCurrentEtag(etag)
})
.catch(() => {
/* silently ignore errors */
})
.finally(() => {
setIsChecking(false)
setLastCheckedAt(Date.now())
})
}, [isEnabled, initialEtag])

// Initial check and setup polling interval
useEffect(() => {
if (!isEnabled) {
return undefined
}

// Do initial check
void checkForDeployment()

// Set up polling
const intervalId = setInterval(() => void checkForDeployment(), POLL_INTERVAL_MS)

return () => {
clearInterval(intervalId)
}
}, [checkForDeployment, isEnabled])

const hasNewDeployment = useMemo(() => {
if (!initialEtag || !currentEtag) {
return false
}
return initialEtag !== currentEtag
}, [initialEtag, currentEtag])

const contextValue: DeploymentNotificationContextValue = useMemo(
() => ({
initialEtag,
currentEtag,
hasNewDeployment,
isChecking,
lastCheckedAt,
checkForDeployment,
}),
[initialEtag, currentEtag, hasNewDeployment, isChecking, lastCheckedAt, checkForDeployment],
)

return (
<DeploymentNotificationContext.Provider value={contextValue}>
{children}
</DeploymentNotificationContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {RefreshIcon} from '@sanity/icons'
import {Box, Flex, Stack, Text, useToast} from '@sanity/ui'
import {useCallback, useEffect, useRef} from 'react'

import {Button} from '../../../ui-components'
import {useTranslation} from '../../i18n/hooks/useTranslation'
import {useDeploymentNotification} from './useDeploymentNotification'

/**
* Component that displays a toast notification when a new deployment is detected
* @internal
*/
export function DeploymentNotificationToast() {
const {t} = useTranslation()
const {hasNewDeployment} = useDeploymentNotification()
const {push: pushToast} = useToast()
const hasShownToastRef = useRef(false)

const handleReload = useCallback(() => {
window.location.reload()
}, [])

useEffect(() => {
// Only show toast once when deployment changes
if (hasNewDeployment && !hasShownToastRef.current) {
hasShownToastRef.current = true

pushToast({
status: 'info',
title: t('deployment-notifier.title'),
closable: true,
description: (
<Stack space={3}>
<Text size={1}>{t('deployment-notifier.description')}</Text>
<Box>
<Flex justify="flex-end">
<Button
icon={RefreshIcon}
mode="ghost"
onClick={handleReload}
text={t('deployment-notifier.reload')}
tone="primary"
/>
</Flex>
</Box>
</Stack>
),
})
}
}, [t, hasNewDeployment, pushToast, handleReload])

return null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Fetches the etag header from the root path to detect deployment changes
*
* @internal
*/
export async function fetchDeploymentEtag(): Promise<string | null> {
try {
const response = await fetch('/', {method: 'HEAD'})
if (!response.ok) {
return null
}

return response.headers.get('etag')
} catch (error) {
console.error('Error fetching deployment etag:', error)
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {DeploymentNotificationProvider} from './DeploymentNotificationProvider'
export {DeploymentNotificationToast} from './DeploymentNotificationToast'
export {fetchDeploymentEtag} from './fetchDeploymentEtag'
export {shouldCheckForDeployment} from './shouldCheckForDeployment'
export {useDeploymentNotification} from './useDeploymentNotification'
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Determines if deployment checking should be enabled based on hostname
*
* @internal
*/
export function shouldCheckForDeployment(): boolean {
// Only run in browser environment and on Sanity domains where we know we can reason
// about the etag value being updated on (and only on) a redeployment
if (typeof window === 'undefined' || !window.location) {
return false
}

// Check if hostname matches *.sanity.studio
const hostname = window.location.hostname
return hostname.endsWith('.sanity.studio') || hostname.endsWith('.studio.sanity.work')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {useContext} from 'react'
import {
DeploymentNotificationContext,
type DeploymentNotificationContextValue,
} from 'sanity/_singletons'

/**
* Hook to access deployment notification status
* @beta
*/
export function useDeploymentNotification(): DeploymentNotificationContextValue {
const context = useContext(DeploymentNotificationContext)

if (!context) {
throw new Error(
'useDeploymentNotification must be used within a DeploymentNotificationProvider',
)
}

return context
}
1 change: 1 addition & 0 deletions packages/sanity/test/__snapshots__/exports.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ exports[`exports snapshot 1`] = `
"CommentsSelectedPathContext": "object",
"CommentsUpsellContext": "object",
"CopyPasteContext": "object",
"DeploymentNotificationContext": "object",
"DiffContext": "object",
"DocumentActionPropsContext": "object",
"DocumentChangeContext": "object",
Expand Down
Loading