diff options
author | Kristóf Marussy <kristof@marussy.com> | 2022-09-06 22:32:04 +0200 |
---|---|---|
committer | Kristóf Marussy <kristof@marussy.com> | 2022-09-08 01:24:50 +0200 |
commit | 5f8f4e6484faff23821ca7c009e309382fba914d (patch) | |
tree | 6d399fb90b4aa3527c30c502ba474cd83b5c94b2 /subprojects | |
parent | feat(frontend): handle page hide events (diff) | |
download | refinery-5f8f4e6484faff23821ca7c009e309382fba914d.tar.gz refinery-5f8f4e6484faff23821ca7c009e309382fba914d.tar.zst refinery-5f8f4e6484faff23821ca7c009e309382fba914d.zip |
feat(frontend): check for updates periodically
Diffstat (limited to 'subprojects')
-rw-r--r-- | subprojects/frontend/package.json | 21 | ||||
-rw-r--r-- | subprojects/frontend/src/App.tsx | 15 | ||||
-rw-r--r-- | subprojects/frontend/src/PWAStore.ts | 94 | ||||
-rw-r--r-- | subprojects/frontend/src/RegisterServiceWorker.tsx | 86 | ||||
-rw-r--r-- | subprojects/frontend/src/RootStore.tsx | 7 | ||||
-rw-r--r-- | subprojects/frontend/src/UpdateNotification.tsx | 51 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/ConnectionStatusNotification.tsx | 49 | ||||
-rw-r--r-- | subprojects/frontend/src/editor/EditorStore.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/index.tsx | 17 | ||||
-rw-r--r-- | subprojects/frontend/src/utils/useDelayedSnackbar.ts | 39 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/OccurrencesService.ts | 5 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextClient.ts | 4 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/XtextWebSocketClient.ts | 3 | ||||
-rw-r--r-- | subprojects/frontend/src/xtext/webSocketMachine.ts | 9 |
14 files changed, 236 insertions, 169 deletions
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 @@ | |||
39 | "@lezer/lr": "^1.2.3", | 39 | "@lezer/lr": "^1.2.3", |
40 | "@material-icons/svg": "^1.0.33", | 40 | "@material-icons/svg": "^1.0.33", |
41 | "@mui/icons-material": "5.10.3", | 41 | "@mui/icons-material": "5.10.3", |
42 | "@mui/material": "5.10.3", | 42 | "@mui/material": "5.10.4", |
43 | "ansi-styles": "^6.1.0", | 43 | "ansi-styles": "^6.1.0", |
44 | "escape-string-regexp": "^5.0.0", | 44 | "escape-string-regexp": "^5.0.0", |
45 | "lodash-es": "^4.17.21", | 45 | "lodash-es": "^4.17.21", |
46 | "loglevel": "^1.8.0", | 46 | "loglevel": "^1.8.0", |
47 | "loglevel-plugin-prefix": "^0.8.4", | 47 | "loglevel-plugin-prefix": "^0.8.4", |
48 | "mobx": "^6.6.1", | 48 | "mobx": "^6.6.2", |
49 | "mobx-react-lite": "^3.4.0", | 49 | "mobx-react-lite": "^3.4.0", |
50 | "ms": "^2.1.3", | ||
50 | "nanoid": "^4.0.0", | 51 | "nanoid": "^4.0.0", |
51 | "notistack": "^2.0.5", | 52 | "notistack": "^2.0.5", |
52 | "react": "^18.2.0", | 53 | "react": "^18.2.0", |
53 | "react-dom": "^18.2.0", | 54 | "react-dom": "^18.2.0", |
54 | "xstate": "^4.33.5", | 55 | "xstate": "^4.33.5", |
55 | "zod": "^3.18.0" | 56 | "zod": "^3.19.0" |
56 | }, | 57 | }, |
57 | "devDependencies": { | 58 | "devDependencies": { |
58 | "@lezer/generator": "^1.1.1", | 59 | "@lezer/generator": "^1.1.1", |
@@ -60,32 +61,32 @@ | |||
60 | "@types/html-minifier-terser": "^7.0.0", | 61 | "@types/html-minifier-terser": "^7.0.0", |
61 | "@types/lodash-es": "^4.17.6", | 62 | "@types/lodash-es": "^4.17.6", |
62 | "@types/ms": "^0.7.31", | 63 | "@types/ms": "^0.7.31", |
63 | "@types/node": "^18.7.15", | 64 | "@types/node": "^18.7.16", |
64 | "@types/prettier": "^2.7.0", | 65 | "@types/prettier": "^2.7.0", |
65 | "@types/react": "^18.0.18", | 66 | "@types/react": "^18.0.18", |
66 | "@types/react-dom": "^18.0.6", | 67 | "@types/react-dom": "^18.0.6", |
67 | "@typescript-eslint/eslint-plugin": "^5.36.1", | 68 | "@typescript-eslint/eslint-plugin": "^5.36.2", |
68 | "@typescript-eslint/parser": "^5.36.1", | 69 | "@typescript-eslint/parser": "^5.36.2", |
69 | "@vitejs/plugin-react": "^2.1.0", | 70 | "@vitejs/plugin-react": "^2.1.0", |
70 | "@xstate/cli": "^0.3.2", | 71 | "@xstate/cli": "^0.3.3", |
71 | "cross-env": "^7.0.3", | 72 | "cross-env": "^7.0.3", |
72 | "eslint": "^8.23.0", | 73 | "eslint": "^8.23.0", |
73 | "eslint-config-airbnb": "^19.0.4", | 74 | "eslint-config-airbnb": "^19.0.4", |
74 | "eslint-config-airbnb-typescript": "^17.0.0", | 75 | "eslint-config-airbnb-typescript": "^17.0.0", |
75 | "eslint-config-prettier": "^8.5.0", | 76 | "eslint-config-prettier": "^8.5.0", |
76 | "eslint-import-resolver-typescript": "^3.5.0", | 77 | "eslint-import-resolver-typescript": "^3.5.1", |
77 | "eslint-plugin-import": "^2.26.0", | 78 | "eslint-plugin-import": "^2.26.0", |
78 | "eslint-plugin-jsx-a11y": "^6.6.1", | 79 | "eslint-plugin-jsx-a11y": "^6.6.1", |
79 | "eslint-plugin-mobx": "^0.0.9", | 80 | "eslint-plugin-mobx": "^0.0.9", |
80 | "eslint-plugin-prettier": "^4.2.1", | 81 | "eslint-plugin-prettier": "^4.2.1", |
81 | "eslint-plugin-react": "^7.31.6", | 82 | "eslint-plugin-react": "^7.31.7", |
82 | "eslint-plugin-react-hooks": "^4.6.0", | 83 | "eslint-plugin-react-hooks": "^4.6.0", |
83 | "html-minifier-terser": "^7.0.0", | 84 | "html-minifier-terser": "^7.0.0", |
84 | "prettier": "^2.7.1", | 85 | "prettier": "^2.7.1", |
85 | "typescript": "~4.8.2", | 86 | "typescript": "~4.8.2", |
86 | "vite": "^3.1.0", | 87 | "vite": "^3.1.0", |
87 | "vite-plugin-inject-preload": "^1.1.0", | 88 | "vite-plugin-inject-preload": "^1.1.0", |
88 | "vite-plugin-pwa": "^0.12.6", | 89 | "vite-plugin-pwa": "^0.12.7", |
89 | "workbox-window": "^6.5.4" | 90 | "workbox-window": "^6.5.4" |
90 | } | 91 | } |
91 | } | 92 | } |
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 @@ | |||
1 | import Grow from '@mui/material/Grow'; | ||
1 | import Stack from '@mui/material/Stack'; | 2 | import Stack from '@mui/material/Stack'; |
3 | import { SnackbarProvider } from 'notistack'; | ||
2 | import React from 'react'; | 4 | import React from 'react'; |
3 | 5 | ||
4 | import TopBar from './TopBar'; | 6 | import TopBar from './TopBar'; |
7 | import UpdateNotification from './UpdateNotification'; | ||
5 | import EditorPane from './editor/EditorPane'; | 8 | import EditorPane from './editor/EditorPane'; |
6 | 9 | ||
7 | export default function App(): JSX.Element { | 10 | export default function App(): JSX.Element { |
8 | return ( | 11 | return ( |
9 | <Stack direction="column" height="100vh" overflow="auto"> | 12 | // @ts-expect-error -- notistack has problems with `exactOptionalPropertyTypes |
10 | <TopBar /> | 13 | <SnackbarProvider TransitionComponent={Grow}> |
11 | <EditorPane /> | 14 | <UpdateNotification /> |
12 | </Stack> | 15 | <Stack direction="column" height="100vh" overflow="auto"> |
16 | <TopBar /> | ||
17 | <EditorPane /> | ||
18 | </Stack> | ||
19 | </SnackbarProvider> | ||
13 | ); | 20 | ); |
14 | } | 21 | } |
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 @@ | |||
1 | import { makeAutoObservable, observable } from 'mobx'; | ||
2 | import ms from 'ms'; | ||
3 | // eslint-disable-next-line import/no-unresolved -- Importing virtual module. | ||
4 | import { registerSW } from 'virtual:pwa-register'; | ||
5 | |||
6 | import getLogger from './utils/getLogger'; | ||
7 | |||
8 | const log = getLogger('PWAStore'); | ||
9 | |||
10 | const UPDATE_INTERVAL = ms('30m'); | ||
11 | |||
12 | export default class PWAStore { | ||
13 | needsUpdate = false; | ||
14 | |||
15 | updateError = false; | ||
16 | |||
17 | private readonly updateSW: ( | ||
18 | reloadPage?: boolean | undefined, | ||
19 | ) => Promise<void>; | ||
20 | |||
21 | private registration: ServiceWorkerRegistration | undefined; | ||
22 | |||
23 | constructor() { | ||
24 | if (window.location.host === 'localhost') { | ||
25 | // Do not register service worker during local development. | ||
26 | this.updateSW = () => Promise.resolve(); | ||
27 | } else { | ||
28 | this.updateSW = registerSW({ | ||
29 | onNeedRefresh: () => this.requestUpdate(), | ||
30 | onOfflineReady() { | ||
31 | log.debug('Service worker is ready for offline use'); | ||
32 | }, | ||
33 | onRegistered: (registration) => { | ||
34 | log.debug('Registered service worker'); | ||
35 | this.setRegistration(registration); | ||
36 | }, | ||
37 | onRegisterError(error) { | ||
38 | log.error('Failed to register service worker', error); | ||
39 | }, | ||
40 | }); | ||
41 | setInterval(() => this.checkForUpdates(), UPDATE_INTERVAL); | ||
42 | } | ||
43 | makeAutoObservable<PWAStore, 'updateSW' | 'registration'>(this, { | ||
44 | updateSW: false, | ||
45 | registration: observable.ref, | ||
46 | }); | ||
47 | } | ||
48 | |||
49 | private requestUpdate(): void { | ||
50 | this.needsUpdate = true; | ||
51 | } | ||
52 | |||
53 | private setRegistration( | ||
54 | registration: ServiceWorkerRegistration | undefined, | ||
55 | ): void { | ||
56 | this.registration = registration; | ||
57 | } | ||
58 | |||
59 | private signalError(): void { | ||
60 | this.updateError = true; | ||
61 | } | ||
62 | |||
63 | private update(reloadPage?: boolean | undefined): void { | ||
64 | this.updateSW(reloadPage).catch((error) => { | ||
65 | log.error('Error while reloading page with updates', error); | ||
66 | this.signalError(); | ||
67 | }); | ||
68 | } | ||
69 | |||
70 | checkForUpdates(): void { | ||
71 | this.dismissError(); | ||
72 | // In development mode, the service worker deactives itself, | ||
73 | // so we must watch out for a deactivated service worker before updating. | ||
74 | if (this.registration !== undefined && this.registration.active) { | ||
75 | this.registration.update().catch((error) => { | ||
76 | log.error('Error while updating service worker', error); | ||
77 | this.signalError(); | ||
78 | }); | ||
79 | } | ||
80 | } | ||
81 | |||
82 | reloadWithUpdate(): void { | ||
83 | this.dismissUpdate(); | ||
84 | this.update(true); | ||
85 | } | ||
86 | |||
87 | dismissUpdate(): void { | ||
88 | this.needsUpdate = false; | ||
89 | } | ||
90 | |||
91 | dismissError(): void { | ||
92 | this.updateError = false; | ||
93 | } | ||
94 | } | ||
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 @@ | |||
1 | import Button from '@mui/material/Button'; | ||
2 | import { | ||
3 | type OptionsObject as SnackbarOptionsObject, | ||
4 | useSnackbar, | ||
5 | } from 'notistack'; | ||
6 | import React, { useEffect } from 'react'; | ||
7 | // eslint-disable-next-line import/no-unresolved -- Importing virtual module. | ||
8 | import { registerSW } from 'virtual:pwa-register'; | ||
9 | |||
10 | import { ContrastThemeProvider } from './theme/ThemeProvider'; | ||
11 | import getLogger from './utils/getLogger'; | ||
12 | |||
13 | const log = getLogger('RegisterServiceWorker'); | ||
14 | |||
15 | function UpdateSnackbarActions({ | ||
16 | closeCurrentSnackbar, | ||
17 | enqueueSnackbar, | ||
18 | updateSW, | ||
19 | }: { | ||
20 | closeCurrentSnackbar: () => void; | ||
21 | enqueueSnackbar: ( | ||
22 | message: string, | ||
23 | options?: SnackbarOptionsObject | undefined, | ||
24 | ) => void; | ||
25 | updateSW: (reloadPage: boolean) => Promise<void>; | ||
26 | }): JSX.Element { | ||
27 | return ( | ||
28 | <ContrastThemeProvider> | ||
29 | <Button | ||
30 | color="primary" | ||
31 | onClick={() => { | ||
32 | closeCurrentSnackbar(); | ||
33 | updateSW(true).catch((error) => { | ||
34 | log.error('Failed to update service worker', error); | ||
35 | enqueueSnackbar('Failed to download update', { | ||
36 | variant: 'error', | ||
37 | }); | ||
38 | }); | ||
39 | }} | ||
40 | > | ||
41 | Reload | ||
42 | </Button> | ||
43 | <Button color="inherit" onClick={closeCurrentSnackbar}> | ||
44 | Dismiss | ||
45 | </Button> | ||
46 | </ContrastThemeProvider> | ||
47 | ); | ||
48 | } | ||
49 | |||
50 | export default function RegisterServiceWorker(): null { | ||
51 | const { enqueueSnackbar, closeSnackbar } = useSnackbar(); | ||
52 | useEffect(() => { | ||
53 | if (window.location.host === 'localhost') { | ||
54 | // Do not register service worker during local development. | ||
55 | return; | ||
56 | } | ||
57 | if (!('serviceWorker' in navigator)) { | ||
58 | log.debug('No service worker support found'); | ||
59 | return; | ||
60 | } | ||
61 | const updateSW = registerSW({ | ||
62 | onNeedRefresh() { | ||
63 | const key = enqueueSnackbar('An update for Refinery is available', { | ||
64 | persist: true, | ||
65 | action: ( | ||
66 | <UpdateSnackbarActions | ||
67 | closeCurrentSnackbar={() => closeSnackbar(key)} | ||
68 | enqueueSnackbar={enqueueSnackbar} | ||
69 | updateSW={updateSW} | ||
70 | /> | ||
71 | ), | ||
72 | }); | ||
73 | }, | ||
74 | onOfflineReady() { | ||
75 | log.debug('Service worker is ready for offline use'); | ||
76 | }, | ||
77 | onRegistered() { | ||
78 | log.debug('Registered service worker'); | ||
79 | }, | ||
80 | onRegisterError(error) { | ||
81 | log.error('Failed to register service worker', error); | ||
82 | }, | ||
83 | }); | ||
84 | }, [enqueueSnackbar, closeSnackbar]); | ||
85 | return null; | ||
86 | } | ||
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'; | |||
2 | import { makeAutoObservable, runInAction } from 'mobx'; | 2 | import { makeAutoObservable, runInAction } from 'mobx'; |
3 | import React, { createContext, useContext } from 'react'; | 3 | import React, { createContext, useContext } from 'react'; |
4 | 4 | ||
5 | import PWAStore from './PWAStore'; | ||
5 | import type EditorStore from './editor/EditorStore'; | 6 | import type EditorStore from './editor/EditorStore'; |
6 | import ThemeStore from './theme/ThemeStore'; | 7 | import ThemeStore from './theme/ThemeStore'; |
7 | 8 | ||
@@ -10,17 +11,21 @@ const log = getLogger('RootStore'); | |||
10 | export default class RootStore { | 11 | export default class RootStore { |
11 | editorStore: EditorStore | undefined; | 12 | editorStore: EditorStore | undefined; |
12 | 13 | ||
14 | readonly pwaStore: PWAStore; | ||
15 | |||
13 | readonly themeStore: ThemeStore; | 16 | readonly themeStore: ThemeStore; |
14 | 17 | ||
15 | constructor(initialValue: string) { | 18 | constructor(initialValue: string) { |
19 | this.pwaStore = new PWAStore(); | ||
16 | this.themeStore = new ThemeStore(); | 20 | this.themeStore = new ThemeStore(); |
17 | makeAutoObservable(this, { | 21 | makeAutoObservable(this, { |
22 | pwaStore: false, | ||
18 | themeStore: false, | 23 | themeStore: false, |
19 | }); | 24 | }); |
20 | import('./editor/EditorStore') | 25 | import('./editor/EditorStore') |
21 | .then(({ default: EditorStore }) => { | 26 | .then(({ default: EditorStore }) => { |
22 | runInAction(() => { | 27 | runInAction(() => { |
23 | this.editorStore = new EditorStore(initialValue); | 28 | this.editorStore = new EditorStore(initialValue, this.pwaStore); |
24 | }); | 29 | }); |
25 | }) | 30 | }) |
26 | .catch((error) => { | 31 | .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 @@ | |||
1 | import Button from '@mui/material/Button'; | ||
2 | import { observer } from 'mobx-react-lite'; | ||
3 | import React, { useEffect } from 'react'; | ||
4 | |||
5 | import { useRootStore } from './RootStore'; | ||
6 | import { ContrastThemeProvider } from './theme/ThemeProvider'; | ||
7 | import useDelayedSnackbar from './utils/useDelayedSnackbar'; | ||
8 | |||
9 | export default observer(function UpdateNotification(): null { | ||
10 | const { pwaStore } = useRootStore(); | ||
11 | const { needsUpdate, updateError } = pwaStore; | ||
12 | const enqueueLater = useDelayedSnackbar(); | ||
13 | |||
14 | useEffect(() => { | ||
15 | if (needsUpdate) { | ||
16 | return enqueueLater('An update for Refinery is available', { | ||
17 | persist: true, | ||
18 | action: ( | ||
19 | <ContrastThemeProvider> | ||
20 | <Button color="primary" onClick={() => pwaStore.reloadWithUpdate()}> | ||
21 | Reload | ||
22 | </Button> | ||
23 | <Button color="inherit" onClick={() => pwaStore.dismissUpdate()}> | ||
24 | Dismiss | ||
25 | </Button> | ||
26 | </ContrastThemeProvider> | ||
27 | ), | ||
28 | }); | ||
29 | } | ||
30 | |||
31 | if (updateError) { | ||
32 | return enqueueLater('Failed to download update', { | ||
33 | variant: 'error', | ||
34 | action: ( | ||
35 | <> | ||
36 | <Button color="inherit" onClick={() => pwaStore.checkForUpdates()}> | ||
37 | Try again | ||
38 | </Button> | ||
39 | <Button color="inherit" onClick={() => pwaStore.dismissError()}> | ||
40 | Dismiss | ||
41 | </Button> | ||
42 | </> | ||
43 | ), | ||
44 | }); | ||
45 | } | ||
46 | |||
47 | return () => {}; | ||
48 | }, [pwaStore, needsUpdate, updateError, enqueueLater]); | ||
49 | |||
50 | return null; | ||
51 | }); | ||
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 @@ | |||
1 | import Button from '@mui/material/Button'; | 1 | import Button from '@mui/material/Button'; |
2 | import { observer } from 'mobx-react-lite'; | 2 | import { observer } from 'mobx-react-lite'; |
3 | import { | ||
4 | useSnackbar, | ||
5 | type SnackbarKey, | ||
6 | type SnackbarMessage, | ||
7 | type OptionsObject, | ||
8 | } from 'notistack'; | ||
9 | import React, { useEffect } from 'react'; | 3 | import React, { useEffect } from 'react'; |
10 | 4 | ||
11 | import { ContrastThemeProvider } from '../theme/ThemeProvider'; | 5 | import { ContrastThemeProvider } from '../theme/ThemeProvider'; |
6 | import useDelayedSnackbar from '../utils/useDelayedSnackbar'; | ||
12 | 7 | ||
13 | import type EditorStore from './EditorStore'; | 8 | import type EditorStore from './EditorStore'; |
14 | 9 | ||
15 | const DEBOUNCE_TIMEOUT = 350; | ||
16 | |||
17 | function enqueueLater( | ||
18 | enqueueSnackbar: ( | ||
19 | message: SnackbarMessage, | ||
20 | options: OptionsObject | undefined, | ||
21 | ) => SnackbarKey, | ||
22 | closeSnackbar: (key: SnackbarKey) => void, | ||
23 | message: SnackbarMessage, | ||
24 | options?: OptionsObject | undefined, | ||
25 | debounceTimeout = DEBOUNCE_TIMEOUT, | ||
26 | ): () => void { | ||
27 | let key: SnackbarKey | undefined; | ||
28 | let timeout: number | undefined = setTimeout(() => { | ||
29 | timeout = undefined; | ||
30 | key = enqueueSnackbar(message, options); | ||
31 | }, debounceTimeout); | ||
32 | return () => { | ||
33 | if (timeout !== undefined) { | ||
34 | clearTimeout(timeout); | ||
35 | } | ||
36 | if (key !== undefined) { | ||
37 | closeSnackbar(key); | ||
38 | } | ||
39 | }; | ||
40 | } | ||
41 | |||
42 | export default observer(function ConnectionStatusNotification({ | 10 | export default observer(function ConnectionStatusNotification({ |
43 | editorStore, | 11 | editorStore, |
44 | }: { | 12 | }: { |
@@ -51,13 +19,11 @@ export default observer(function ConnectionStatusNotification({ | |||
51 | disconnectedByUser, | 19 | disconnectedByUser, |
52 | networkMissing, | 20 | networkMissing, |
53 | } = editorStore; | 21 | } = editorStore; |
54 | const { enqueueSnackbar, closeSnackbar } = useSnackbar(); | 22 | const enqueueLater = useDelayedSnackbar(350); |
55 | 23 | ||
56 | useEffect(() => { | 24 | useEffect(() => { |
57 | if (opening) { | 25 | if (opening) { |
58 | return enqueueLater( | 26 | return enqueueLater( |
59 | enqueueSnackbar, | ||
60 | closeSnackbar, | ||
61 | 'Connecting to Refinery', | 27 | 'Connecting to Refinery', |
62 | { | 28 | { |
63 | persist: true, | 29 | persist: true, |
@@ -73,8 +39,6 @@ export default observer(function ConnectionStatusNotification({ | |||
73 | 39 | ||
74 | if (connectionErrors.length >= 1 && !opening) { | 40 | if (connectionErrors.length >= 1 && !opening) { |
75 | return enqueueLater( | 41 | return enqueueLater( |
76 | enqueueSnackbar, | ||
77 | closeSnackbar, | ||
78 | <div> | 42 | <div> |
79 | Connection error:{' '} | 43 | Connection error:{' '} |
80 | <b>{connectionErrors[connectionErrors.length - 1]}</b> | 44 | <b>{connectionErrors[connectionErrors.length - 1]}</b> |
@@ -110,8 +74,6 @@ export default observer(function ConnectionStatusNotification({ | |||
110 | if (networkMissing) { | 74 | if (networkMissing) { |
111 | if (disconnectedByUser) { | 75 | if (disconnectedByUser) { |
112 | return enqueueLater( | 76 | return enqueueLater( |
113 | enqueueSnackbar, | ||
114 | closeSnackbar, | ||
115 | <div> | 77 | <div> |
116 | <b>No network connection:</b> Some editing features might be | 78 | <b>No network connection:</b> Some editing features might be |
117 | degraded | 79 | degraded |
@@ -130,8 +92,6 @@ export default observer(function ConnectionStatusNotification({ | |||
130 | } | 92 | } |
131 | 93 | ||
132 | return enqueueLater( | 94 | return enqueueLater( |
133 | enqueueSnackbar, | ||
134 | closeSnackbar, | ||
135 | <div> | 95 | <div> |
136 | <b>No network connection:</b> Refinery will try to reconnect when the | 96 | <b>No network connection:</b> Refinery will try to reconnect when the |
137 | connection is restored | 97 | connection is restored |
@@ -154,8 +114,6 @@ export default observer(function ConnectionStatusNotification({ | |||
154 | 114 | ||
155 | if (disconnectedByUser) { | 115 | if (disconnectedByUser) { |
156 | return enqueueLater( | 116 | return enqueueLater( |
157 | enqueueSnackbar, | ||
158 | closeSnackbar, | ||
159 | <div> | 117 | <div> |
160 | <b>Not connected to Refinery:</b> Some editing features might be | 118 | <b>Not connected to Refinery:</b> Some editing features might be |
161 | degraded | 119 | degraded |
@@ -181,8 +139,7 @@ export default observer(function ConnectionStatusNotification({ | |||
181 | connectionErrors, | 139 | connectionErrors, |
182 | disconnectedByUser, | 140 | disconnectedByUser, |
183 | networkMissing, | 141 | networkMissing, |
184 | closeSnackbar, | 142 | enqueueLater, |
185 | enqueueSnackbar, | ||
186 | ]); | 143 | ]); |
187 | 144 | ||
188 | return null; | 145 | 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'; | |||
16 | import { makeAutoObservable, observable } from 'mobx'; | 16 | import { makeAutoObservable, observable } from 'mobx'; |
17 | import { nanoid } from 'nanoid'; | 17 | import { nanoid } from 'nanoid'; |
18 | 18 | ||
19 | import type PWAStore from '../PWAStore'; | ||
19 | import getLogger from '../utils/getLogger'; | 20 | import getLogger from '../utils/getLogger'; |
20 | import XtextClient from '../xtext/XtextClient'; | 21 | import XtextClient from '../xtext/XtextClient'; |
21 | 22 | ||
@@ -51,10 +52,10 @@ export default class EditorStore { | |||
51 | 52 | ||
52 | infoCount = 0; | 53 | infoCount = 0; |
53 | 54 | ||
54 | constructor(initialValue: string) { | 55 | constructor(initialValue: string, pwaStore: PWAStore) { |
55 | this.id = nanoid(); | 56 | this.id = nanoid(); |
56 | this.state = createEditorState(initialValue, this); | 57 | this.state = createEditorState(initialValue, this); |
57 | this.client = new XtextClient(this); | 58 | this.client = new XtextClient(this, pwaStore); |
58 | this.searchPanel = new SearchPanelStore(this); | 59 | this.searchPanel = new SearchPanelStore(this); |
59 | this.lintPanel = new LintPanelStore(this); | 60 | this.lintPanel = new LintPanelStore(this); |
60 | makeAutoObservable<EditorStore, 'client'>(this, { | 61 | makeAutoObservable<EditorStore, 'client'>(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 @@ | |||
1 | import Box from '@mui/material/Box'; | 1 | import Box from '@mui/material/Box'; |
2 | import CssBaseline from '@mui/material/CssBaseline'; | 2 | import CssBaseline from '@mui/material/CssBaseline'; |
3 | import Grow from '@mui/material/Grow'; | ||
4 | import { configure } from 'mobx'; | 3 | import { configure } from 'mobx'; |
5 | import { SnackbarProvider } from 'notistack'; | ||
6 | import React, { Suspense, lazy } from 'react'; | 4 | import React, { Suspense, lazy } from 'react'; |
7 | import { createRoot } from 'react-dom/client'; | 5 | import { createRoot } from 'react-dom/client'; |
8 | 6 | ||
9 | import Loading from './Loading'; | 7 | import Loading from './Loading'; |
10 | import RegisterServiceWorker from './RegisterServiceWorker'; | ||
11 | import RootStore, { RootStoreProvider } from './RootStore'; | 8 | import RootStore, { RootStoreProvider } from './RootStore'; |
12 | import WindowControlsOverlayColor from './WindowControlsOverlayColor'; | 9 | import WindowControlsOverlayColor from './WindowControlsOverlayColor'; |
13 | import ThemeProvider from './theme/ThemeProvider'; | 10 | import ThemeProvider from './theme/ThemeProvider'; |
@@ -78,15 +75,11 @@ const app = ( | |||
78 | <ThemeProvider> | 75 | <ThemeProvider> |
79 | <CssBaseline enableColorScheme /> | 76 | <CssBaseline enableColorScheme /> |
80 | <WindowControlsOverlayColor /> | 77 | <WindowControlsOverlayColor /> |
81 | {/* @ts-expect-error -- notistack has problems with `exactOptionalPropertyTypes` */} | 78 | <Box height="100vh" overflow="auto"> |
82 | <SnackbarProvider TransitionComponent={Grow}> | 79 | <Suspense fallback={<Loading />}> |
83 | <RegisterServiceWorker /> | 80 | <App /> |
84 | <Box height="100vh" overflow="auto"> | 81 | </Suspense> |
85 | <Suspense fallback={<Loading />}> | 82 | </Box> |
86 | <App /> | ||
87 | </Suspense> | ||
88 | </Box> | ||
89 | </SnackbarProvider> | ||
90 | </ThemeProvider> | 83 | </ThemeProvider> |
91 | </RootStoreProvider> | 84 | </RootStoreProvider> |
92 | </React.StrictMode> | 85 | </React.StrictMode> |
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 @@ | |||
1 | import { | ||
2 | useSnackbar, | ||
3 | type SnackbarKey, | ||
4 | type SnackbarMessage, | ||
5 | type OptionsObject, | ||
6 | } from 'notistack'; | ||
7 | import { useCallback } from 'react'; | ||
8 | |||
9 | export default function useDelayedSnackbar( | ||
10 | defaultDelay = 0, | ||
11 | ): ( | ||
12 | message: SnackbarMessage, | ||
13 | options?: OptionsObject | undefined, | ||
14 | delay?: number | undefined, | ||
15 | ) => () => void { | ||
16 | const { enqueueSnackbar, closeSnackbar } = useSnackbar(); | ||
17 | return useCallback( | ||
18 | ( | ||
19 | message: SnackbarMessage, | ||
20 | options?: OptionsObject | undefined, | ||
21 | delay = defaultDelay, | ||
22 | ) => { | ||
23 | let key: SnackbarKey | undefined; | ||
24 | let timeout: number | undefined = setTimeout(() => { | ||
25 | timeout = undefined; | ||
26 | key = enqueueSnackbar(message, options); | ||
27 | }, delay); | ||
28 | return () => { | ||
29 | if (timeout !== undefined) { | ||
30 | clearTimeout(timeout); | ||
31 | } | ||
32 | if (key !== undefined) { | ||
33 | closeSnackbar(key); | ||
34 | } | ||
35 | }; | ||
36 | }, | ||
37 | [defaultDelay, enqueueSnackbar, closeSnackbar], | ||
38 | ); | ||
39 | } | ||
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 @@ | |||
1 | import { Transaction } from '@codemirror/state'; | 1 | import { Transaction } from '@codemirror/state'; |
2 | import { debounce } from 'lodash-es'; | 2 | import { debounce } from 'lodash-es'; |
3 | import ms from 'ms'; | ||
3 | 4 | ||
4 | import type EditorStore from '../editor/EditorStore'; | 5 | import type EditorStore from '../editor/EditorStore'; |
5 | import { | 6 | import { |
@@ -11,7 +12,7 @@ import getLogger from '../utils/getLogger'; | |||
11 | import type UpdateService from './UpdateService'; | 12 | import type UpdateService from './UpdateService'; |
12 | import type { TextRegion } from './xtextServiceResults'; | 13 | import type { TextRegion } from './xtextServiceResults'; |
13 | 14 | ||
14 | const FIND_OCCURRENCES_TIMEOUT_MS = 1000; | 15 | const FIND_OCCURRENCES_TIMEOUT = ms('1s'); |
15 | 16 | ||
16 | const log = getLogger('xtext.OccurrencesService'); | 17 | const log = getLogger('xtext.OccurrencesService'); |
17 | 18 | ||
@@ -33,7 +34,7 @@ export default class OccurrencesService { | |||
33 | 34 | ||
34 | private readonly findOccurrencesLater = debounce( | 35 | private readonly findOccurrencesLater = debounce( |
35 | () => this.findOccurrences(), | 36 | () => this.findOccurrences(), |
36 | FIND_OCCURRENCES_TIMEOUT_MS, | 37 | FIND_OCCURRENCES_TIMEOUT, |
37 | ); | 38 | ); |
38 | 39 | ||
39 | constructor( | 40 | 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 { | |||
4 | } from '@codemirror/autocomplete'; | 4 | } from '@codemirror/autocomplete'; |
5 | import type { Transaction } from '@codemirror/state'; | 5 | import type { Transaction } from '@codemirror/state'; |
6 | 6 | ||
7 | import type PWAStore from '../PWAStore'; | ||
7 | import type EditorStore from '../editor/EditorStore'; | 8 | import type EditorStore from '../editor/EditorStore'; |
8 | import getLogger from '../utils/getLogger'; | 9 | import getLogger from '../utils/getLogger'; |
9 | 10 | ||
@@ -30,7 +31,7 @@ export default class XtextClient { | |||
30 | 31 | ||
31 | private readonly occurrencesService: OccurrencesService; | 32 | private readonly occurrencesService: OccurrencesService; |
32 | 33 | ||
33 | constructor(store: EditorStore) { | 34 | constructor(store: EditorStore, private readonly pwaStore: PWAStore) { |
34 | this.webSocketClient = new XtextWebSocketClient( | 35 | this.webSocketClient = new XtextWebSocketClient( |
35 | () => this.onReconnect(), | 36 | () => this.onReconnect(), |
36 | () => this.onDisconnect(), | 37 | () => this.onDisconnect(), |
@@ -54,6 +55,7 @@ export default class XtextClient { | |||
54 | private onReconnect(): void { | 55 | private onReconnect(): void { |
55 | this.updateService.onReconnect(); | 56 | this.updateService.onReconnect(); |
56 | this.occurrencesService.onReconnect(); | 57 | this.occurrencesService.onReconnect(); |
58 | this.pwaStore.checkForUpdates(); | ||
57 | } | 59 | } |
58 | 60 | ||
59 | private onDisconnect(): void { | 61 | 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 @@ | |||
1 | import { createAtom, makeAutoObservable, observable } from 'mobx'; | 1 | import { createAtom, makeAutoObservable, observable } from 'mobx'; |
2 | import ms from 'ms'; | ||
2 | import { nanoid } from 'nanoid'; | 3 | import { nanoid } from 'nanoid'; |
3 | import { interpret } from 'xstate'; | 4 | import { interpret } from 'xstate'; |
4 | 5 | ||
@@ -18,7 +19,7 @@ const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; | |||
18 | 19 | ||
19 | // Use a large enough timeout so that a request can complete successfully | 20 | // Use a large enough timeout so that a request can complete successfully |
20 | // even if the browser has throttled the background tab. | 21 | // even if the browser has throttled the background tab. |
21 | const REQUEST_TIMEOUT = 5000; | 22 | const REQUEST_TIMEOUT = ms('5s'); |
22 | 23 | ||
23 | const log = getLogger('xtext.XtextWebSocketClient'); | 24 | const log = getLogger('xtext.XtextWebSocketClient'); |
24 | 25 | ||
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 @@ | |||
1 | import ms from 'ms'; | ||
1 | import { actions, assign, createMachine, RaiseAction } from 'xstate'; | 2 | import { actions, assign, createMachine, RaiseAction } from 'xstate'; |
2 | 3 | ||
3 | const { raise } = actions; | 4 | const { raise } = actions; |
4 | 5 | ||
5 | const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000]; | 6 | const ERROR_WAIT_TIMES = ['200', '1s', '5s', '30s'].map(ms); |
6 | 7 | ||
7 | export interface WebSocketContext { | 8 | export interface WebSocketContext { |
8 | webSocketURL: string | undefined; | 9 | webSocketURL: string | undefined; |
@@ -229,9 +230,9 @@ export default createMachine( | |||
229 | needsNetwork: ({ webSocketURL }) => !isWebSocketURLLocal(webSocketURL), | 230 | needsNetwork: ({ webSocketURL }) => !isWebSocketURLLocal(webSocketURL), |
230 | }, | 231 | }, |
231 | delays: { | 232 | delays: { |
232 | IDLE_TIMEOUT: 300_000, | 233 | IDLE_TIMEOUT: ms('5m'), |
233 | OPEN_TIMEOUT: 10_000, | 234 | OPEN_TIMEOUT: ms('10s'), |
234 | PING_PERIOD: 10_000, | 235 | PING_PERIOD: ms('10s'), |
235 | ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => { | 236 | ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => { |
236 | const { length } = ERROR_WAIT_TIMES; | 237 | const { length } = ERROR_WAIT_TIMES; |
237 | const index = retryCount < length ? retryCount : length - 1; | 238 | const index = retryCount < length ? retryCount : length - 1; |