From 5f8f4e6484faff23821ca7c009e309382fba914d Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 6 Sep 2022 22:32:04 +0200 Subject: feat(frontend): check for updates periodically --- subprojects/frontend/package.json | 21 ++--- subprojects/frontend/src/App.tsx | 15 +++- subprojects/frontend/src/PWAStore.ts | 94 ++++++++++++++++++++++ subprojects/frontend/src/RegisterServiceWorker.tsx | 86 -------------------- subprojects/frontend/src/RootStore.tsx | 7 +- subprojects/frontend/src/UpdateNotification.tsx | 51 ++++++++++++ .../src/editor/ConnectionStatusNotification.tsx | 49 +---------- subprojects/frontend/src/editor/EditorStore.ts | 5 +- subprojects/frontend/src/index.tsx | 17 ++-- .../frontend/src/utils/useDelayedSnackbar.ts | 39 +++++++++ .../frontend/src/xtext/OccurrencesService.ts | 5 +- subprojects/frontend/src/xtext/XtextClient.ts | 4 +- .../frontend/src/xtext/XtextWebSocketClient.ts | 3 +- subprojects/frontend/src/xtext/webSocketMachine.ts | 9 ++- 14 files changed, 236 insertions(+), 169 deletions(-) create mode 100644 subprojects/frontend/src/PWAStore.ts delete mode 100644 subprojects/frontend/src/RegisterServiceWorker.tsx create mode 100644 subprojects/frontend/src/UpdateNotification.tsx create mode 100644 subprojects/frontend/src/utils/useDelayedSnackbar.ts (limited to 'subprojects') diff --git a/subprojects/frontend/package.json b/subprojects/frontend/package.json index 5856bb47..af345777 100644 --- a/subprojects/frontend/package.json +++ b/subprojects/frontend/package.json @@ -39,20 +39,21 @@ "@lezer/lr": "^1.2.3", "@material-icons/svg": "^1.0.33", "@mui/icons-material": "5.10.3", - "@mui/material": "5.10.3", + "@mui/material": "5.10.4", "ansi-styles": "^6.1.0", "escape-string-regexp": "^5.0.0", "lodash-es": "^4.17.21", "loglevel": "^1.8.0", "loglevel-plugin-prefix": "^0.8.4", - "mobx": "^6.6.1", + "mobx": "^6.6.2", "mobx-react-lite": "^3.4.0", + "ms": "^2.1.3", "nanoid": "^4.0.0", "notistack": "^2.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", "xstate": "^4.33.5", - "zod": "^3.18.0" + "zod": "^3.19.0" }, "devDependencies": { "@lezer/generator": "^1.1.1", @@ -60,32 +61,32 @@ "@types/html-minifier-terser": "^7.0.0", "@types/lodash-es": "^4.17.6", "@types/ms": "^0.7.31", - "@types/node": "^18.7.15", + "@types/node": "^18.7.16", "@types/prettier": "^2.7.0", "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", - "@typescript-eslint/eslint-plugin": "^5.36.1", - "@typescript-eslint/parser": "^5.36.1", + "@typescript-eslint/eslint-plugin": "^5.36.2", + "@typescript-eslint/parser": "^5.36.2", "@vitejs/plugin-react": "^2.1.0", - "@xstate/cli": "^0.3.2", + "@xstate/cli": "^0.3.3", "cross-env": "^7.0.3", "eslint": "^8.23.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", "eslint-config-prettier": "^8.5.0", - "eslint-import-resolver-typescript": "^3.5.0", + "eslint-import-resolver-typescript": "^3.5.1", "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-mobx": "^0.0.9", "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.31.6", + "eslint-plugin-react": "^7.31.7", "eslint-plugin-react-hooks": "^4.6.0", "html-minifier-terser": "^7.0.0", "prettier": "^2.7.1", "typescript": "~4.8.2", "vite": "^3.1.0", "vite-plugin-inject-preload": "^1.1.0", - "vite-plugin-pwa": "^0.12.6", + "vite-plugin-pwa": "^0.12.7", "workbox-window": "^6.5.4" } } diff --git a/subprojects/frontend/src/App.tsx b/subprojects/frontend/src/App.tsx index 3a25f43a..90514044 100644 --- a/subprojects/frontend/src/App.tsx +++ b/subprojects/frontend/src/App.tsx @@ -1,14 +1,21 @@ +import Grow from '@mui/material/Grow'; import Stack from '@mui/material/Stack'; +import { SnackbarProvider } from 'notistack'; import React from 'react'; import TopBar from './TopBar'; +import UpdateNotification from './UpdateNotification'; import EditorPane from './editor/EditorPane'; export default function App(): JSX.Element { return ( - - - - + // @ts-expect-error -- notistack has problems with `exactOptionalPropertyTypes + + + + + + + ); } diff --git a/subprojects/frontend/src/PWAStore.ts b/subprojects/frontend/src/PWAStore.ts new file mode 100644 index 00000000..e9f99e2a --- /dev/null +++ b/subprojects/frontend/src/PWAStore.ts @@ -0,0 +1,94 @@ +import { makeAutoObservable, observable } from 'mobx'; +import ms from 'ms'; +// eslint-disable-next-line import/no-unresolved -- Importing virtual module. +import { registerSW } from 'virtual:pwa-register'; + +import getLogger from './utils/getLogger'; + +const log = getLogger('PWAStore'); + +const UPDATE_INTERVAL = ms('30m'); + +export default class PWAStore { + needsUpdate = false; + + updateError = false; + + private readonly updateSW: ( + reloadPage?: boolean | undefined, + ) => Promise; + + private registration: ServiceWorkerRegistration | undefined; + + constructor() { + if (window.location.host === 'localhost') { + // Do not register service worker during local development. + this.updateSW = () => Promise.resolve(); + } else { + this.updateSW = registerSW({ + onNeedRefresh: () => this.requestUpdate(), + onOfflineReady() { + log.debug('Service worker is ready for offline use'); + }, + onRegistered: (registration) => { + log.debug('Registered service worker'); + this.setRegistration(registration); + }, + onRegisterError(error) { + log.error('Failed to register service worker', error); + }, + }); + setInterval(() => this.checkForUpdates(), UPDATE_INTERVAL); + } + makeAutoObservable(this, { + updateSW: false, + registration: observable.ref, + }); + } + + private requestUpdate(): void { + this.needsUpdate = true; + } + + private setRegistration( + registration: ServiceWorkerRegistration | undefined, + ): void { + this.registration = registration; + } + + private signalError(): void { + this.updateError = true; + } + + private update(reloadPage?: boolean | undefined): void { + this.updateSW(reloadPage).catch((error) => { + log.error('Error while reloading page with updates', error); + this.signalError(); + }); + } + + checkForUpdates(): void { + this.dismissError(); + // In development mode, the service worker deactives itself, + // so we must watch out for a deactivated service worker before updating. + if (this.registration !== undefined && this.registration.active) { + this.registration.update().catch((error) => { + log.error('Error while updating service worker', error); + this.signalError(); + }); + } + } + + reloadWithUpdate(): void { + this.dismissUpdate(); + this.update(true); + } + + dismissUpdate(): void { + this.needsUpdate = false; + } + + dismissError(): void { + this.updateError = false; + } +} diff --git a/subprojects/frontend/src/RegisterServiceWorker.tsx b/subprojects/frontend/src/RegisterServiceWorker.tsx deleted file mode 100644 index 5f46bc3d..00000000 --- a/subprojects/frontend/src/RegisterServiceWorker.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import Button from '@mui/material/Button'; -import { - type OptionsObject as SnackbarOptionsObject, - useSnackbar, -} from 'notistack'; -import React, { useEffect } from 'react'; -// eslint-disable-next-line import/no-unresolved -- Importing virtual module. -import { registerSW } from 'virtual:pwa-register'; - -import { ContrastThemeProvider } from './theme/ThemeProvider'; -import getLogger from './utils/getLogger'; - -const log = getLogger('RegisterServiceWorker'); - -function UpdateSnackbarActions({ - closeCurrentSnackbar, - enqueueSnackbar, - updateSW, -}: { - closeCurrentSnackbar: () => void; - enqueueSnackbar: ( - message: string, - options?: SnackbarOptionsObject | undefined, - ) => void; - updateSW: (reloadPage: boolean) => Promise; -}): JSX.Element { - return ( - - - - - ); -} - -export default function RegisterServiceWorker(): null { - const { enqueueSnackbar, closeSnackbar } = useSnackbar(); - useEffect(() => { - if (window.location.host === 'localhost') { - // Do not register service worker during local development. - return; - } - if (!('serviceWorker' in navigator)) { - log.debug('No service worker support found'); - return; - } - const updateSW = registerSW({ - onNeedRefresh() { - const key = enqueueSnackbar('An update for Refinery is available', { - persist: true, - action: ( - closeSnackbar(key)} - enqueueSnackbar={enqueueSnackbar} - updateSW={updateSW} - /> - ), - }); - }, - onOfflineReady() { - log.debug('Service worker is ready for offline use'); - }, - onRegistered() { - log.debug('Registered service worker'); - }, - onRegisterError(error) { - log.error('Failed to register service worker', error); - }, - }); - }, [enqueueSnackbar, closeSnackbar]); - return null; -} diff --git a/subprojects/frontend/src/RootStore.tsx b/subprojects/frontend/src/RootStore.tsx index 5aa580d1..c1674a3c 100644 --- a/subprojects/frontend/src/RootStore.tsx +++ b/subprojects/frontend/src/RootStore.tsx @@ -2,6 +2,7 @@ import { getLogger } from 'loglevel'; import { makeAutoObservable, runInAction } from 'mobx'; import React, { createContext, useContext } from 'react'; +import PWAStore from './PWAStore'; import type EditorStore from './editor/EditorStore'; import ThemeStore from './theme/ThemeStore'; @@ -10,17 +11,21 @@ const log = getLogger('RootStore'); export default class RootStore { editorStore: EditorStore | undefined; + readonly pwaStore: PWAStore; + readonly themeStore: ThemeStore; constructor(initialValue: string) { + this.pwaStore = new PWAStore(); this.themeStore = new ThemeStore(); makeAutoObservable(this, { + pwaStore: false, themeStore: false, }); import('./editor/EditorStore') .then(({ default: EditorStore }) => { runInAction(() => { - this.editorStore = new EditorStore(initialValue); + this.editorStore = new EditorStore(initialValue, this.pwaStore); }); }) .catch((error) => { diff --git a/subprojects/frontend/src/UpdateNotification.tsx b/subprojects/frontend/src/UpdateNotification.tsx new file mode 100644 index 00000000..d260e3b7 --- /dev/null +++ b/subprojects/frontend/src/UpdateNotification.tsx @@ -0,0 +1,51 @@ +import Button from '@mui/material/Button'; +import { observer } from 'mobx-react-lite'; +import React, { useEffect } from 'react'; + +import { useRootStore } from './RootStore'; +import { ContrastThemeProvider } from './theme/ThemeProvider'; +import useDelayedSnackbar from './utils/useDelayedSnackbar'; + +export default observer(function UpdateNotification(): null { + const { pwaStore } = useRootStore(); + const { needsUpdate, updateError } = pwaStore; + const enqueueLater = useDelayedSnackbar(); + + useEffect(() => { + if (needsUpdate) { + return enqueueLater('An update for Refinery is available', { + persist: true, + action: ( + + + + + ), + }); + } + + if (updateError) { + return enqueueLater('Failed to download update', { + variant: 'error', + action: ( + <> + + + + ), + }); + } + + return () => {}; + }, [pwaStore, needsUpdate, updateError, enqueueLater]); + + return null; +}); diff --git a/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx index 54c4e834..f7f089f0 100644 --- a/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx +++ b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx @@ -1,44 +1,12 @@ import Button from '@mui/material/Button'; import { observer } from 'mobx-react-lite'; -import { - useSnackbar, - type SnackbarKey, - type SnackbarMessage, - type OptionsObject, -} from 'notistack'; import React, { useEffect } from 'react'; import { ContrastThemeProvider } from '../theme/ThemeProvider'; +import useDelayedSnackbar from '../utils/useDelayedSnackbar'; import type EditorStore from './EditorStore'; -const DEBOUNCE_TIMEOUT = 350; - -function enqueueLater( - enqueueSnackbar: ( - message: SnackbarMessage, - options: OptionsObject | undefined, - ) => SnackbarKey, - closeSnackbar: (key: SnackbarKey) => void, - message: SnackbarMessage, - options?: OptionsObject | undefined, - debounceTimeout = DEBOUNCE_TIMEOUT, -): () => void { - let key: SnackbarKey | undefined; - let timeout: number | undefined = setTimeout(() => { - timeout = undefined; - key = enqueueSnackbar(message, options); - }, debounceTimeout); - return () => { - if (timeout !== undefined) { - clearTimeout(timeout); - } - if (key !== undefined) { - closeSnackbar(key); - } - }; -} - export default observer(function ConnectionStatusNotification({ editorStore, }: { @@ -51,13 +19,11 @@ export default observer(function ConnectionStatusNotification({ disconnectedByUser, networkMissing, } = editorStore; - const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const enqueueLater = useDelayedSnackbar(350); useEffect(() => { if (opening) { return enqueueLater( - enqueueSnackbar, - closeSnackbar, 'Connecting to Refinery', { persist: true, @@ -73,8 +39,6 @@ export default observer(function ConnectionStatusNotification({ if (connectionErrors.length >= 1 && !opening) { return enqueueLater( - enqueueSnackbar, - closeSnackbar,
Connection error:{' '} {connectionErrors[connectionErrors.length - 1]} @@ -110,8 +74,6 @@ export default observer(function ConnectionStatusNotification({ if (networkMissing) { if (disconnectedByUser) { return enqueueLater( - enqueueSnackbar, - closeSnackbar,
No network connection: Some editing features might be degraded @@ -130,8 +92,6 @@ export default observer(function ConnectionStatusNotification({ } return enqueueLater( - enqueueSnackbar, - closeSnackbar,
No network connection: Refinery will try to reconnect when the connection is restored @@ -154,8 +114,6 @@ export default observer(function ConnectionStatusNotification({ if (disconnectedByUser) { return enqueueLater( - enqueueSnackbar, - closeSnackbar,
Not connected to Refinery: Some editing features might be degraded @@ -181,8 +139,7 @@ export default observer(function ConnectionStatusNotification({ connectionErrors, disconnectedByUser, networkMissing, - closeSnackbar, - enqueueSnackbar, + enqueueLater, ]); return null; diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts index ecbe6ef8..c74e732f 100644 --- a/subprojects/frontend/src/editor/EditorStore.ts +++ b/subprojects/frontend/src/editor/EditorStore.ts @@ -16,6 +16,7 @@ import { type Command, EditorView } from '@codemirror/view'; import { makeAutoObservable, observable } from 'mobx'; import { nanoid } from 'nanoid'; +import type PWAStore from '../PWAStore'; import getLogger from '../utils/getLogger'; import XtextClient from '../xtext/XtextClient'; @@ -51,10 +52,10 @@ export default class EditorStore { infoCount = 0; - constructor(initialValue: string) { + constructor(initialValue: string, pwaStore: PWAStore) { this.id = nanoid(); this.state = createEditorState(initialValue, this); - this.client = new XtextClient(this); + this.client = new XtextClient(this, pwaStore); this.searchPanel = new SearchPanelStore(this); this.lintPanel = new LintPanelStore(this); makeAutoObservable(this, { diff --git a/subprojects/frontend/src/index.tsx b/subprojects/frontend/src/index.tsx index 5760327e..55e0590f 100644 --- a/subprojects/frontend/src/index.tsx +++ b/subprojects/frontend/src/index.tsx @@ -1,13 +1,10 @@ import Box from '@mui/material/Box'; import CssBaseline from '@mui/material/CssBaseline'; -import Grow from '@mui/material/Grow'; import { configure } from 'mobx'; -import { SnackbarProvider } from 'notistack'; import React, { Suspense, lazy } from 'react'; import { createRoot } from 'react-dom/client'; import Loading from './Loading'; -import RegisterServiceWorker from './RegisterServiceWorker'; import RootStore, { RootStoreProvider } from './RootStore'; import WindowControlsOverlayColor from './WindowControlsOverlayColor'; import ThemeProvider from './theme/ThemeProvider'; @@ -78,15 +75,11 @@ const app = ( - {/* @ts-expect-error -- notistack has problems with `exactOptionalPropertyTypes` */} - - - - }> - - - - + + }> + + + diff --git a/subprojects/frontend/src/utils/useDelayedSnackbar.ts b/subprojects/frontend/src/utils/useDelayedSnackbar.ts new file mode 100644 index 00000000..03ad6caa --- /dev/null +++ b/subprojects/frontend/src/utils/useDelayedSnackbar.ts @@ -0,0 +1,39 @@ +import { + useSnackbar, + type SnackbarKey, + type SnackbarMessage, + type OptionsObject, +} from 'notistack'; +import { useCallback } from 'react'; + +export default function useDelayedSnackbar( + defaultDelay = 0, +): ( + message: SnackbarMessage, + options?: OptionsObject | undefined, + delay?: number | undefined, +) => () => void { + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + return useCallback( + ( + message: SnackbarMessage, + options?: OptionsObject | undefined, + delay = defaultDelay, + ) => { + let key: SnackbarKey | undefined; + let timeout: number | undefined = setTimeout(() => { + timeout = undefined; + key = enqueueSnackbar(message, options); + }, delay); + return () => { + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (key !== undefined) { + closeSnackbar(key); + } + }; + }, + [defaultDelay, enqueueSnackbar, closeSnackbar], + ); +} diff --git a/subprojects/frontend/src/xtext/OccurrencesService.ts b/subprojects/frontend/src/xtext/OccurrencesService.ts index 9d738d76..248a9a87 100644 --- a/subprojects/frontend/src/xtext/OccurrencesService.ts +++ b/subprojects/frontend/src/xtext/OccurrencesService.ts @@ -1,5 +1,6 @@ import { Transaction } from '@codemirror/state'; import { debounce } from 'lodash-es'; +import ms from 'ms'; import type EditorStore from '../editor/EditorStore'; import { @@ -11,7 +12,7 @@ import getLogger from '../utils/getLogger'; import type UpdateService from './UpdateService'; import type { TextRegion } from './xtextServiceResults'; -const FIND_OCCURRENCES_TIMEOUT_MS = 1000; +const FIND_OCCURRENCES_TIMEOUT = ms('1s'); const log = getLogger('xtext.OccurrencesService'); @@ -33,7 +34,7 @@ export default class OccurrencesService { private readonly findOccurrencesLater = debounce( () => this.findOccurrences(), - FIND_OCCURRENCES_TIMEOUT_MS, + FIND_OCCURRENCES_TIMEOUT, ); constructor( diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts index 1f7e446f..cd5d280d 100644 --- a/subprojects/frontend/src/xtext/XtextClient.ts +++ b/subprojects/frontend/src/xtext/XtextClient.ts @@ -4,6 +4,7 @@ import type { } from '@codemirror/autocomplete'; import type { Transaction } from '@codemirror/state'; +import type PWAStore from '../PWAStore'; import type EditorStore from '../editor/EditorStore'; import getLogger from '../utils/getLogger'; @@ -30,7 +31,7 @@ export default class XtextClient { private readonly occurrencesService: OccurrencesService; - constructor(store: EditorStore) { + constructor(store: EditorStore, private readonly pwaStore: PWAStore) { this.webSocketClient = new XtextWebSocketClient( () => this.onReconnect(), () => this.onDisconnect(), @@ -54,6 +55,7 @@ export default class XtextClient { private onReconnect(): void { this.updateService.onReconnect(); this.occurrencesService.onReconnect(); + this.pwaStore.checkForUpdates(); } private onDisconnect(): void { diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts index cba6f064..a39620cb 100644 --- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts +++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts @@ -1,4 +1,5 @@ import { createAtom, makeAutoObservable, observable } from 'mobx'; +import ms from 'ms'; import { nanoid } from 'nanoid'; import { interpret } from 'xstate'; @@ -18,7 +19,7 @@ const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; // Use a large enough timeout so that a request can complete successfully // even if the browser has throttled the background tab. -const REQUEST_TIMEOUT = 5000; +const REQUEST_TIMEOUT = ms('5s'); const log = getLogger('xtext.XtextWebSocketClient'); diff --git a/subprojects/frontend/src/xtext/webSocketMachine.ts b/subprojects/frontend/src/xtext/webSocketMachine.ts index 25689cec..a1eee781 100644 --- a/subprojects/frontend/src/xtext/webSocketMachine.ts +++ b/subprojects/frontend/src/xtext/webSocketMachine.ts @@ -1,8 +1,9 @@ +import ms from 'ms'; import { actions, assign, createMachine, RaiseAction } from 'xstate'; const { raise } = actions; -const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000]; +const ERROR_WAIT_TIMES = ['200', '1s', '5s', '30s'].map(ms); export interface WebSocketContext { webSocketURL: string | undefined; @@ -229,9 +230,9 @@ export default createMachine( needsNetwork: ({ webSocketURL }) => !isWebSocketURLLocal(webSocketURL), }, delays: { - IDLE_TIMEOUT: 300_000, - OPEN_TIMEOUT: 10_000, - PING_PERIOD: 10_000, + IDLE_TIMEOUT: ms('5m'), + OPEN_TIMEOUT: ms('10s'), + PING_PERIOD: ms('10s'), ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => { const { length } = ERROR_WAIT_TIMES; const index = retryCount < length ? retryCount : length - 1; -- cgit v1.2.3-54-g00ecf