diff --git a/build/api/interface.api.md b/build/api/interface.api.md index 913664778..b65c22f38 100644 --- a/build/api/interface.api.md +++ b/build/api/interface.api.md @@ -104,6 +104,11 @@ export const RedirectOnPersist: ({ to }: { to: RoutingLinkTarget; }) => null; +// @public (undocumented) +export const useIsApplicationOutdated: ({ checkIntervalMs }?: { + checkIntervalMs?: number | undefined; +}) => boolean; + export * from "@contember/react-binding"; export * from "@contember/react-identity"; diff --git a/packages/interface/src/hooks/index.ts b/packages/interface/src/hooks/index.ts new file mode 100644 index 000000000..9dd45bdad --- /dev/null +++ b/packages/interface/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useIsApplicationOutdated' diff --git a/packages/interface/src/hooks/useIsApplicationOutdated.ts b/packages/interface/src/hooks/useIsApplicationOutdated.ts new file mode 100644 index 000000000..a979bf05e --- /dev/null +++ b/packages/interface/src/hooks/useIsApplicationOutdated.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from 'react' + +const defaultCheckIntervalMs = 30_000 + + +export const useIsApplicationOutdated = ({ checkIntervalMs = defaultCheckIntervalMs }: { + checkIntervalMs?: number +} = {}) => { + const [initialVersion] = useState(() => { + return getVersionFromDocument(document.head) + }) + const [isOutdated, setIsOutdated] = useState(false) + const fetchVersion = useFetchVersion() + + const checkVersion = useCallback(async () => { + if (document.hidden) { + return + } + const version = await fetchVersion() + if (version !== initialVersion || Math.random() < 0.3) { + setIsOutdated(true) + } + }, [fetchVersion, initialVersion]) + const shouldCheck = !!initialVersion && !isOutdated + + useEffect(() => { + if (!shouldCheck) { + return + } + const handle = setInterval(checkVersion, checkIntervalMs) + return () => clearInterval(handle) + }, [checkIntervalMs, checkVersion, shouldCheck]) + + useEffect(() => { + if (!shouldCheck) { + return + } + document.addEventListener('visibilitychange', checkVersion) + return () => document.removeEventListener('visibilitychange', checkVersion) + }, [checkVersion, shouldCheck]) + + return isOutdated +} + +const getVersionFromDocument = (node: ParentNode) => { + const meta = node.querySelector('meta[name=contember-build-version]') + return meta?.content +} + +const useFetchVersion = () => { + return useCallback(async () => { + try { + const result = await (await fetch(window.location.href)).text() + const html = document.createElement('html') + html.innerHTML = result + return getVersionFromDocument(html) + } catch (e) { + console.error(e) + return undefined + } + }, []) +} diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 630d8d38d..d09cce71b 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -1,5 +1,6 @@ export * from './bootstrap' export * from './components' +export * from './hooks' export * from '@contember/react-routing' export * from '@contember/react-binding' export * from '@contember/react-identity' diff --git a/packages/playground/admin/index.tsx b/packages/playground/admin/index.tsx index a97f9faf5..0c9bc56b3 100644 --- a/packages/playground/admin/index.tsx +++ b/packages/playground/admin/index.tsx @@ -8,6 +8,7 @@ import { LogInIcon } from 'lucide-react' import { LoginWithEmail } from './lib/components/dev/login-panel' import { createRoot } from 'react-dom/client' import { getConfig } from './config' +import { OutdatedApplicationDialog } from './lib/components/outdated-application-dialog' const errorHandler = createErrorHandler((dom, react, onRecoverableError) => createRoot(dom, { onRecoverableError }).render(react)) @@ -27,6 +28,7 @@ errorHandler(onRecoverableError => createRoot(rootEl, { onRecoverableError }).re { eager: true }, )} /> + {import.meta.env.DEV && }> } diff --git a/packages/playground/admin/lib/components/outdated-application-dialog.tsx b/packages/playground/admin/lib/components/outdated-application-dialog.tsx new file mode 100644 index 000000000..573349c72 --- /dev/null +++ b/packages/playground/admin/lib/components/outdated-application-dialog.tsx @@ -0,0 +1,41 @@ +import { ComponentType, useCallback, useEffect, useState } from 'react' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader } from './ui/alert-dialog' +import { useIsApplicationOutdated } from '@contember/interface' +import { ClockIcon, RefreshCwIcon } from 'lucide-react' +import { dict } from '../dict' + +const postponeTimeoutMs = 60_000 * 5 +const checkIntervalMs = 30_000 + +export const OutdatedApplicationDialog: ComponentType = () => { + const isOutdated = useIsApplicationOutdated({ checkIntervalMs }) + const [open, setOpen] = useState(true) + + const postpone = useCallback(() => { + setOpen(false) + setTimeout(() => setOpen(true), postponeTimeoutMs) + }, []) + + return ( + !it ? postpone() : null}> + + {dict.outdatedApplication.title} + {dict.outdatedApplication.description} + +
{dict.outdatedApplication.warning}
+ + + + {dict.outdatedApplication.snooze} + + window.location.reload()} className="gap-1"> + + {dict.outdatedApplication.refreshNow} + + +
+
+ ) +} + + diff --git a/packages/playground/admin/lib/dict.ts b/packages/playground/admin/lib/dict.ts index cfed3fcf1..a72d3b9ba 100644 --- a/packages/playground/admin/lib/dict.ts +++ b/packages/playground/admin/lib/dict.ts @@ -112,4 +112,12 @@ export const dict = { empty: 'No items.', addItem: 'Add item', }, + outdatedApplication: { + title: 'An updated version is available', + description: 'To access the latest features and improvements, kindly refresh your browser.', + warning: 'Any unsaved data will be lost. Please save your work before refreshing.', + snooze: 'Snooze', + refreshNow: 'Refresh now', + + }, }