aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
Diffstat (limited to 'subprojects')
-rw-r--r--subprojects/frontend/package.json21
-rw-r--r--subprojects/frontend/src/App.tsx15
-rw-r--r--subprojects/frontend/src/PWAStore.ts94
-rw-r--r--subprojects/frontend/src/RegisterServiceWorker.tsx86
-rw-r--r--subprojects/frontend/src/RootStore.tsx7
-rw-r--r--subprojects/frontend/src/UpdateNotification.tsx51
-rw-r--r--subprojects/frontend/src/editor/ConnectionStatusNotification.tsx49
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts5
-rw-r--r--subprojects/frontend/src/index.tsx17
-rw-r--r--subprojects/frontend/src/utils/useDelayedSnackbar.ts39
-rw-r--r--subprojects/frontend/src/xtext/OccurrencesService.ts5
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts4
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts3
-rw-r--r--subprojects/frontend/src/xtext/webSocketMachine.ts9
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 @@
1import Grow from '@mui/material/Grow';
1import Stack from '@mui/material/Stack'; 2import Stack from '@mui/material/Stack';
3import { SnackbarProvider } from 'notistack';
2import React from 'react'; 4import React from 'react';
3 5
4import TopBar from './TopBar'; 6import TopBar from './TopBar';
7import UpdateNotification from './UpdateNotification';
5import EditorPane from './editor/EditorPane'; 8import EditorPane from './editor/EditorPane';
6 9
7export default function App(): JSX.Element { 10export 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 @@
1import { makeAutoObservable, observable } from 'mobx';
2import ms from 'ms';
3// eslint-disable-next-line import/no-unresolved -- Importing virtual module.
4import { registerSW } from 'virtual:pwa-register';
5
6import getLogger from './utils/getLogger';
7
8const log = getLogger('PWAStore');
9
10const UPDATE_INTERVAL = ms('30m');
11
12export 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 @@
1import Button from '@mui/material/Button';
2import {
3 type OptionsObject as SnackbarOptionsObject,
4 useSnackbar,
5} from 'notistack';
6import React, { useEffect } from 'react';
7// eslint-disable-next-line import/no-unresolved -- Importing virtual module.
8import { registerSW } from 'virtual:pwa-register';
9
10import { ContrastThemeProvider } from './theme/ThemeProvider';
11import getLogger from './utils/getLogger';
12
13const log = getLogger('RegisterServiceWorker');
14
15function 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
50export 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';
2import { makeAutoObservable, runInAction } from 'mobx'; 2import { makeAutoObservable, runInAction } from 'mobx';
3import React, { createContext, useContext } from 'react'; 3import React, { createContext, useContext } from 'react';
4 4
5import PWAStore from './PWAStore';
5import type EditorStore from './editor/EditorStore'; 6import type EditorStore from './editor/EditorStore';
6import ThemeStore from './theme/ThemeStore'; 7import ThemeStore from './theme/ThemeStore';
7 8
@@ -10,17 +11,21 @@ const log = getLogger('RootStore');
10export default class RootStore { 11export 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 @@
1import Button from '@mui/material/Button';
2import { observer } from 'mobx-react-lite';
3import React, { useEffect } from 'react';
4
5import { useRootStore } from './RootStore';
6import { ContrastThemeProvider } from './theme/ThemeProvider';
7import useDelayedSnackbar from './utils/useDelayedSnackbar';
8
9export 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 @@
1import Button from '@mui/material/Button'; 1import Button from '@mui/material/Button';
2import { observer } from 'mobx-react-lite'; 2import { observer } from 'mobx-react-lite';
3import {
4 useSnackbar,
5 type SnackbarKey,
6 type SnackbarMessage,
7 type OptionsObject,
8} from 'notistack';
9import React, { useEffect } from 'react'; 3import React, { useEffect } from 'react';
10 4
11import { ContrastThemeProvider } from '../theme/ThemeProvider'; 5import { ContrastThemeProvider } from '../theme/ThemeProvider';
6import useDelayedSnackbar from '../utils/useDelayedSnackbar';
12 7
13import type EditorStore from './EditorStore'; 8import type EditorStore from './EditorStore';
14 9
15const DEBOUNCE_TIMEOUT = 350;
16
17function 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
42export default observer(function ConnectionStatusNotification({ 10export 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';
16import { makeAutoObservable, observable } from 'mobx'; 16import { makeAutoObservable, observable } from 'mobx';
17import { nanoid } from 'nanoid'; 17import { nanoid } from 'nanoid';
18 18
19import type PWAStore from '../PWAStore';
19import getLogger from '../utils/getLogger'; 20import getLogger from '../utils/getLogger';
20import XtextClient from '../xtext/XtextClient'; 21import 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 @@
1import Box from '@mui/material/Box'; 1import Box from '@mui/material/Box';
2import CssBaseline from '@mui/material/CssBaseline'; 2import CssBaseline from '@mui/material/CssBaseline';
3import Grow from '@mui/material/Grow';
4import { configure } from 'mobx'; 3import { configure } from 'mobx';
5import { SnackbarProvider } from 'notistack';
6import React, { Suspense, lazy } from 'react'; 4import React, { Suspense, lazy } from 'react';
7import { createRoot } from 'react-dom/client'; 5import { createRoot } from 'react-dom/client';
8 6
9import Loading from './Loading'; 7import Loading from './Loading';
10import RegisterServiceWorker from './RegisterServiceWorker';
11import RootStore, { RootStoreProvider } from './RootStore'; 8import RootStore, { RootStoreProvider } from './RootStore';
12import WindowControlsOverlayColor from './WindowControlsOverlayColor'; 9import WindowControlsOverlayColor from './WindowControlsOverlayColor';
13import ThemeProvider from './theme/ThemeProvider'; 10import 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 @@
1import {
2 useSnackbar,
3 type SnackbarKey,
4 type SnackbarMessage,
5 type OptionsObject,
6} from 'notistack';
7import { useCallback } from 'react';
8
9export 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 @@
1import { Transaction } from '@codemirror/state'; 1import { Transaction } from '@codemirror/state';
2import { debounce } from 'lodash-es'; 2import { debounce } from 'lodash-es';
3import ms from 'ms';
3 4
4import type EditorStore from '../editor/EditorStore'; 5import type EditorStore from '../editor/EditorStore';
5import { 6import {
@@ -11,7 +12,7 @@ import getLogger from '../utils/getLogger';
11import type UpdateService from './UpdateService'; 12import type UpdateService from './UpdateService';
12import type { TextRegion } from './xtextServiceResults'; 13import type { TextRegion } from './xtextServiceResults';
13 14
14const FIND_OCCURRENCES_TIMEOUT_MS = 1000; 15const FIND_OCCURRENCES_TIMEOUT = ms('1s');
15 16
16const log = getLogger('xtext.OccurrencesService'); 17const 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';
5import type { Transaction } from '@codemirror/state'; 5import type { Transaction } from '@codemirror/state';
6 6
7import type PWAStore from '../PWAStore';
7import type EditorStore from '../editor/EditorStore'; 8import type EditorStore from '../editor/EditorStore';
8import getLogger from '../utils/getLogger'; 9import 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 @@
1import { createAtom, makeAutoObservable, observable } from 'mobx'; 1import { createAtom, makeAutoObservable, observable } from 'mobx';
2import ms from 'ms';
2import { nanoid } from 'nanoid'; 3import { nanoid } from 'nanoid';
3import { interpret } from 'xstate'; 4import { 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.
21const REQUEST_TIMEOUT = 5000; 22const REQUEST_TIMEOUT = ms('5s');
22 23
23const log = getLogger('xtext.XtextWebSocketClient'); 24const 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 @@
1import ms from 'ms';
1import { actions, assign, createMachine, RaiseAction } from 'xstate'; 2import { actions, assign, createMachine, RaiseAction } from 'xstate';
2 3
3const { raise } = actions; 4const { raise } = actions;
4 5
5const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000]; 6const ERROR_WAIT_TIMES = ['200', '1s', '5s', '30s'].map(ms);
6 7
7export interface WebSocketContext { 8export 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;