aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects
diff options
context:
space:
mode:
authorLibravatar Kristóf Marussy <kristof@marussy.com>2022-09-06 11:30:32 +0200
committerLibravatar Kristóf Marussy <kristof@marussy.com>2022-09-06 22:31:51 +0200
commit292e8998f3e7d106a91954e345a70f4cf3a317a8 (patch)
tree9ba04853ca3e3a883b29a34fdf461e6230439e01 /subprojects
parentrefactor(frontend): toolbar sm breakpoint (diff)
downloadrefinery-292e8998f3e7d106a91954e345a70f4cf3a317a8.tar.gz
refinery-292e8998f3e7d106a91954e345a70f4cf3a317a8.tar.zst
refinery-292e8998f3e7d106a91954e345a70f4cf3a317a8.zip
feat(frontend): handle page hide events
Integrate better with the page lifecycle state machine, see https://developer.chrome.com/blog/page-lifecycle-api/ Also makes disconnected notifications less noisy, since they may occur more frequently now (due to a frozen page being resumed).
Diffstat (limited to 'subprojects')
-rw-r--r--subprojects/frontend/src/editor/ConnectionStatusNotification.tsx143
-rw-r--r--subprojects/frontend/src/editor/EditorStore.ts9
-rw-r--r--subprojects/frontend/src/xtext/XtextClient.ts4
-rw-r--r--subprojects/frontend/src/xtext/XtextWebSocketClient.ts79
-rw-r--r--subprojects/frontend/src/xtext/webSocketMachine.ts129
5 files changed, 274 insertions, 90 deletions
diff --git a/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx
index e402e296..54c4e834 100644
--- a/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx
+++ b/subprojects/frontend/src/editor/ConnectionStatusNotification.tsx
@@ -1,50 +1,83 @@
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 { type SnackbarKey, useSnackbar } from 'notistack'; 3import {
4 useSnackbar,
5 type SnackbarKey,
6 type SnackbarMessage,
7 type OptionsObject,
8} from 'notistack';
4import React, { useEffect } from 'react'; 9import React, { useEffect } from 'react';
5 10
6import { ContrastThemeProvider } from '../theme/ThemeProvider'; 11import { ContrastThemeProvider } from '../theme/ThemeProvider';
7 12
8import type EditorStore from './EditorStore'; 13import type EditorStore from './EditorStore';
9 14
10const CONNECTING_DEBOUNCE_TIMEOUT = 250; 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}
11 41
12export default observer(function ConnectionStatusNotification({ 42export default observer(function ConnectionStatusNotification({
13 editorStore, 43 editorStore,
14}: { 44}: {
15 editorStore: EditorStore; 45 editorStore: EditorStore;
16}): null { 46}): null {
17 const { opened, opening, connectionErrors } = editorStore; 47 const {
48 opened,
49 opening,
50 connectionErrors,
51 disconnectedByUser,
52 networkMissing,
53 } = editorStore;
18 const { enqueueSnackbar, closeSnackbar } = useSnackbar(); 54 const { enqueueSnackbar, closeSnackbar } = useSnackbar();
19 55
20 useEffect(() => { 56 useEffect(() => {
21 if (opening) { 57 if (opening) {
22 let key: SnackbarKey | undefined; 58 return enqueueLater(
23 let timeout: number | undefined = setTimeout(() => { 59 enqueueSnackbar,
24 timeout = undefined; 60 closeSnackbar,
25 key = enqueueSnackbar('Connecting to Refinery', { 61 'Connecting to Refinery',
62 {
26 persist: true, 63 persist: true,
27 action: ( 64 action: (
28 <Button onClick={() => editorStore.disconnect()} color="inherit"> 65 <Button onClick={() => editorStore.disconnect()} color="inherit">
29 Cancel 66 Cancel
30 </Button> 67 </Button>
31 ), 68 ),
32 }); 69 },
33 }, CONNECTING_DEBOUNCE_TIMEOUT); 70 500,
34 return () => { 71 );
35 if (timeout !== undefined) {
36 clearTimeout(timeout);
37 }
38 if (key !== undefined) {
39 closeSnackbar(key);
40 }
41 };
42 } 72 }
43 73
44 if (connectionErrors.length >= 1) { 74 if (connectionErrors.length >= 1 && !opening) {
45 const key = enqueueSnackbar( 75 return enqueueLater(
76 enqueueSnackbar,
77 closeSnackbar,
46 <div> 78 <div>
47 Connection error: <b>{connectionErrors[0]}</b> 79 Connection error:{' '}
80 <b>{connectionErrors[connectionErrors.length - 1]}</b>
48 {connectionErrors.length >= 2 && ( 81 {connectionErrors.length >= 2 && (
49 <> 82 <>
50 {' '} 83 {' '}
@@ -57,28 +90,72 @@ export default observer(function ConnectionStatusNotification({
57 persist: !opened, 90 persist: !opened,
58 variant: 'error', 91 variant: 'error',
59 action: opened ? ( 92 action: opened ? (
60 <ContrastThemeProvider> 93 <Button onClick={() => editorStore.disconnect()} color="inherit">
61 <Button onClick={() => editorStore.disconnect()} color="inherit"> 94 Disconnect
62 Disconnect 95 </Button>
63 </Button>
64 </ContrastThemeProvider>
65 ) : ( 96 ) : (
66 <ContrastThemeProvider> 97 <>
67 <Button onClick={() => editorStore.connect()} color="inherit"> 98 <Button onClick={() => editorStore.connect()} color="inherit">
68 Reconnect 99 Reconnect
69 </Button> 100 </Button>
70 <Button onClick={() => editorStore.disconnect()} color="inherit"> 101 <Button onClick={() => editorStore.disconnect()} color="inherit">
71 Cancel 102 Cancel
72 </Button> 103 </Button>
104 </>
105 ),
106 },
107 );
108 }
109
110 if (networkMissing) {
111 if (disconnectedByUser) {
112 return enqueueLater(
113 enqueueSnackbar,
114 closeSnackbar,
115 <div>
116 <b>No network connection:</b> Some editing features might be
117 degraded
118 </div>,
119 {
120 action: (
121 <ContrastThemeProvider>
122 <Button onClick={() => editorStore.connect()} color="primary">
123 Try reconnecting
124 </Button>
125 </ContrastThemeProvider>
126 ),
127 },
128 0,
129 );
130 }
131
132 return enqueueLater(
133 enqueueSnackbar,
134 closeSnackbar,
135 <div>
136 <b>No network connection:</b> Refinery will try to reconnect when the
137 connection is restored
138 </div>,
139 {
140 persist: true,
141 action: (
142 <ContrastThemeProvider>
143 <Button onClick={() => editorStore.connect()} color="primary">
144 Try now
145 </Button>
146 <Button onClick={() => editorStore.disconnect()} color="inherit">
147 Cancel
148 </Button>
73 </ContrastThemeProvider> 149 </ContrastThemeProvider>
74 ), 150 ),
75 }, 151 },
76 ); 152 );
77 return () => closeSnackbar(key);
78 } 153 }
79 154
80 if (!opened) { 155 if (disconnectedByUser) {
81 const key = enqueueSnackbar( 156 return enqueueLater(
157 enqueueSnackbar,
158 closeSnackbar,
82 <div> 159 <div>
83 <b>Not connected to Refinery:</b> Some editing features might be 160 <b>Not connected to Refinery:</b> Some editing features might be
84 degraded 161 degraded
@@ -86,12 +163,14 @@ export default observer(function ConnectionStatusNotification({
86 { 163 {
87 action: ( 164 action: (
88 <ContrastThemeProvider> 165 <ContrastThemeProvider>
89 <Button onClick={() => editorStore.connect()}>Reconnect</Button> 166 <Button onClick={() => editorStore.connect()} color="primary">
167 Reconnect
168 </Button>
90 </ContrastThemeProvider> 169 </ContrastThemeProvider>
91 ), 170 ),
92 }, 171 },
172 0,
93 ); 173 );
94 return () => closeSnackbar(key);
95 } 174 }
96 175
97 return () => {}; 176 return () => {};
@@ -100,6 +179,8 @@ export default observer(function ConnectionStatusNotification({
100 opened, 179 opened,
101 opening, 180 opening,
102 connectionErrors, 181 connectionErrors,
182 disconnectedByUser,
183 networkMissing,
103 closeSnackbar, 184 closeSnackbar,
104 enqueueSnackbar, 185 enqueueSnackbar,
105 ]); 186 ]);
diff --git a/subprojects/frontend/src/editor/EditorStore.ts b/subprojects/frontend/src/editor/EditorStore.ts
index 3ec33b2c..ecbe6ef8 100644
--- a/subprojects/frontend/src/editor/EditorStore.ts
+++ b/subprojects/frontend/src/editor/EditorStore.ts
@@ -67,6 +67,7 @@ export default class EditorStore {
67 contentAssist: false, 67 contentAssist: false,
68 formatText: false, 68 formatText: false,
69 }); 69 });
70 this.client.start();
70 } 71 }
71 72
72 get opened(): boolean { 73 get opened(): boolean {
@@ -77,6 +78,14 @@ export default class EditorStore {
77 return this.client.webSocketClient.opening; 78 return this.client.webSocketClient.opening;
78 } 79 }
79 80
81 get disconnectedByUser(): boolean {
82 return this.client.webSocketClient.disconnectedByUser;
83 }
84
85 get networkMissing(): boolean {
86 return this.client.webSocketClient.networkMissing;
87 }
88
80 get connectionErrors(): string[] { 89 get connectionErrors(): string[] {
81 return this.client.webSocketClient.errors; 90 return this.client.webSocketClient.errors;
82 } 91 }
diff --git a/subprojects/frontend/src/xtext/XtextClient.ts b/subprojects/frontend/src/xtext/XtextClient.ts
index c02afb3b..1f7e446f 100644
--- a/subprojects/frontend/src/xtext/XtextClient.ts
+++ b/subprojects/frontend/src/xtext/XtextClient.ts
@@ -47,6 +47,10 @@ export default class XtextClient {
47 this.occurrencesService = new OccurrencesService(store, this.updateService); 47 this.occurrencesService = new OccurrencesService(store, this.updateService);
48 } 48 }
49 49
50 start(): void {
51 this.webSocketClient.start();
52 }
53
50 private onReconnect(): void { 54 private onReconnect(): void {
51 this.updateService.onReconnect(); 55 this.updateService.onReconnect();
52 this.occurrencesService.onReconnect(); 56 this.occurrencesService.onReconnect();
diff --git a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
index b69e1d6c..cba6f064 100644
--- a/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
+++ b/subprojects/frontend/src/xtext/XtextWebSocketClient.ts
@@ -6,7 +6,7 @@ import CancelledError from '../utils/CancelledError';
6import PendingTask from '../utils/PendingTask'; 6import PendingTask from '../utils/PendingTask';
7import getLogger from '../utils/getLogger'; 7import getLogger from '../utils/getLogger';
8 8
9import webSocketMachine from './webSocketMachine'; 9import webSocketMachine, { isWebSocketURLLocal } from './webSocketMachine';
10import { 10import {
11 type XtextWebPushService, 11 type XtextWebPushService,
12 XtextResponse, 12 XtextResponse,
@@ -16,7 +16,9 @@ import { PongResult } from './xtextServiceResults';
16 16
17const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; 17const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1';
18 18
19const REQUEST_TIMEOUT = 1000; 19// Use a large enough timeout so that a request can complete successfully
20// even if the browser has throttled the background tab.
21const REQUEST_TIMEOUT = 5000;
20 22
21const log = getLogger('xtext.XtextWebSocketClient'); 23const log = getLogger('xtext.XtextWebSocketClient');
22 24
@@ -52,6 +54,7 @@ export default class XtextWebSocketClient {
52 openWebSocket: ({ webSocketURL }) => this.openWebSocket(webSocketURL), 54 openWebSocket: ({ webSocketURL }) => this.openWebSocket(webSocketURL),
53 closeWebSocket: () => this.closeWebSocket(), 55 closeWebSocket: () => this.closeWebSocket(),
54 notifyReconnect: () => this.onReconnect(), 56 notifyReconnect: () => this.onReconnect(),
57 notifyDisconnect: () => this.onDisconnect(),
55 cancelPendingRequests: () => this.cancelPendingRequests(), 58 cancelPendingRequests: () => this.cancelPendingRequests(),
56 }, 59 },
57 services: { 60 services: {
@@ -141,20 +144,6 @@ export default class XtextWebSocketClient {
141 private readonly onDisconnect: DisconnectHandler, 144 private readonly onDisconnect: DisconnectHandler,
142 private readonly onPush: PushHandler, 145 private readonly onPush: PushHandler,
143 ) { 146 ) {
144 this.interpreter
145 .onTransition((state, event) => {
146 log.trace('WebSocke state transition', state.value, 'on event', event);
147 this.stateAtom.reportChanged();
148 })
149 .start();
150
151 this.updateVisibility();
152 document.addEventListener('visibilitychange', () =>
153 this.updateVisibility(),
154 );
155
156 this.interpreter.send('CONNECT');
157
158 makeAutoObservable< 147 makeAutoObservable<
159 XtextWebSocketClient, 148 XtextWebSocketClient,
160 | 'stateAtom' 149 | 'stateAtom'
@@ -177,6 +166,40 @@ export default class XtextWebSocketClient {
177 }); 166 });
178 } 167 }
179 168
169 start(): void {
170 this.interpreter
171 .onTransition((state, event) => {
172 log.trace('WebSocke state transition', state.value, 'on event', event);
173 this.stateAtom.reportChanged();
174 })
175 .start();
176
177 this.interpreter.send(window.navigator.onLine ? 'ONLINE' : 'OFFLINE');
178 window.addEventListener('offline', () => this.interpreter.send('OFFLINE'));
179 window.addEventListener('online', () => this.interpreter.send('ONLINE'));
180 this.updateVisibility();
181 document.addEventListener('visibilitychange', () =>
182 this.updateVisibility(),
183 );
184 window.addEventListener('pagehide', () =>
185 this.interpreter.send('PAGE_HIDE'),
186 );
187 window.addEventListener('pageshow', () => {
188 this.updateVisibility();
189 this.interpreter.send('PAGE_SHOW');
190 });
191 // https://developer.chrome.com/blog/page-lifecycle-api/#new-features-added-in-chrome-68
192 if ('wasDiscarded' in document) {
193 document.addEventListener('freeze', () =>
194 this.interpreter.send('PAGE_FREEZE'),
195 );
196 document.addEventListener('resume', () =>
197 this.interpreter.send('PAGE_RESUME'),
198 );
199 }
200 this.interpreter.send('CONNECT');
201 }
202
180 get state() { 203 get state() {
181 this.stateAtom.reportObserved(); 204 this.stateAtom.reportObserved();
182 return this.interpreter.state; 205 return this.interpreter.state;
@@ -190,6 +213,19 @@ export default class XtextWebSocketClient {
190 return this.state.matches('connection.socketCreated.open.opened'); 213 return this.state.matches('connection.socketCreated.open.opened');
191 } 214 }
192 215
216 get disconnectedByUser(): boolean {
217 return this.state.matches('connection.disconnected');
218 }
219
220 get networkMissing(): boolean {
221 return (
222 this.state.matches('connection.temporarilyOffline') ||
223 (this.disconnectedByUser &&
224 this.state.matches('network.offline') &&
225 !isWebSocketURLLocal(this.state.context.webSocketURL))
226 );
227 }
228
193 get errors(): string[] { 229 get errors(): string[] {
194 return this.state.context.errors; 230 return this.state.context.errors;
195 } 231 }
@@ -217,9 +253,13 @@ export default class XtextWebSocketClient {
217 const id = nanoid(); 253 const id = nanoid();
218 254
219 const promise = new Promise((resolve, reject) => { 255 const promise = new Promise((resolve, reject) => {
220 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => 256 const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => {
221 this.removeTask(id), 257 this.interpreter.send({
222 ); 258 type: 'ERROR',
259 message: 'Connection timed out',
260 });
261 this.removeTask(id);
262 });
223 this.pendingRequests.set(id, task); 263 this.pendingRequests.set(id, task);
224 }); 264 });
225 265
@@ -272,7 +312,6 @@ export default class XtextWebSocketClient {
272 } 312 }
273 313
274 private cancelPendingRequests(): void { 314 private cancelPendingRequests(): void {
275 this.onDisconnect();
276 this.pendingRequests.forEach((task) => 315 this.pendingRequests.forEach((task) =>
277 task.reject(new CancelledError('Closing connection')), 316 task.reject(new CancelledError('Closing connection')),
278 ); 317 );
diff --git a/subprojects/frontend/src/xtext/webSocketMachine.ts b/subprojects/frontend/src/xtext/webSocketMachine.ts
index 50eb36a0..25689cec 100644
--- a/subprojects/frontend/src/xtext/webSocketMachine.ts
+++ b/subprojects/frontend/src/xtext/webSocketMachine.ts
@@ -7,7 +7,6 @@ const ERROR_WAIT_TIMES = [200, 1000, 5000, 30_000];
7export interface WebSocketContext { 7export interface WebSocketContext {
8 webSocketURL: string | undefined; 8 webSocketURL: string | undefined;
9 errors: string[]; 9 errors: string[];
10 retryCount: number;
11} 10}
12 11
13export type WebSocketEvent = 12export type WebSocketEvent =
@@ -17,8 +16,33 @@ export type WebSocketEvent =
17 | { type: 'OPENED' } 16 | { type: 'OPENED' }
18 | { type: 'TAB_VISIBLE' } 17 | { type: 'TAB_VISIBLE' }
19 | { type: 'TAB_HIDDEN' } 18 | { type: 'TAB_HIDDEN' }
19 | { type: 'PAGE_HIDE' }
20 | { type: 'PAGE_SHOW' }
21 | { type: 'PAGE_FREEZE' }
22 | { type: 'PAGE_RESUME' }
23 | { type: 'ONLINE' }
24 | { type: 'OFFLINE' }
20 | { type: 'ERROR'; message: string }; 25 | { type: 'ERROR'; message: string };
21 26
27export function isWebSocketURLLocal(webSocketURL: string | undefined): boolean {
28 if (webSocketURL === undefined) {
29 return false;
30 }
31 let hostname: string;
32 try {
33 ({ hostname } = new URL(webSocketURL));
34 } catch {
35 return false;
36 }
37 // https://stackoverflow.com/a/57949518
38 return (
39 hostname === 'localhost' ||
40 hostname === '[::1]' ||
41 hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) !==
42 null
43 );
44}
45
22export default createMachine( 46export default createMachine(
23 { 47 {
24 id: 'webSocket', 48 id: 'webSocket',
@@ -31,32 +55,65 @@ export default createMachine(
31 context: { 55 context: {
32 webSocketURL: undefined, 56 webSocketURL: undefined,
33 errors: [], 57 errors: [],
34 retryCount: 0,
35 }, 58 },
36 type: 'parallel', 59 type: 'parallel',
37 states: { 60 states: {
38 connection: { 61 connection: {
39 initial: 'disconnected', 62 initial: 'disconnected',
63 entry: 'clearErrors',
40 states: { 64 states: {
41 disconnected: { 65 disconnected: {
42 id: 'disconnected', 66 id: 'disconnected',
67 entry: ['clearErrors', 'notifyDisconnect'],
43 on: { 68 on: {
44 CONFIGURE: { actions: 'configure' }, 69 CONFIGURE: { actions: 'configure' },
45 }, 70 },
46 }, 71 },
47 timedOut: { 72 timedOut: {
48 id: 'timedOut', 73 id: 'timedOut',
74 always: [
75 {
76 target: 'temporarilyOffline',
77 cond: 'needsNetwork',
78 in: '#offline',
79 },
80 { target: 'socketCreated', in: '#tabVisible' },
81 ],
49 on: { 82 on: {
50 TAB_VISIBLE: 'socketCreated', 83 PAGE_HIDE: 'pageHidden',
84 PAGE_FREEZE: 'pageHidden',
51 }, 85 },
52 }, 86 },
53 errorWait: { 87 errorWait: {
54 id: 'errorWait', 88 id: 'errorWait',
89 always: [
90 {
91 target: 'temporarilyOffline',
92 cond: 'needsNetwork',
93 in: '#offline',
94 },
95 ],
55 after: { 96 after: {
56 ERROR_WAIT_TIME: [ 97 ERROR_WAIT_TIME: 'timedOut',
57 { target: 'timedOut', in: '#tabHidden' }, 98 },
58 { target: 'socketCreated' }, 99 on: {
59 ], 100 PAGE_HIDE: 'pageHidden',
101 PAGE_FREEZE: 'pageHidden',
102 },
103 },
104 temporarilyOffline: {
105 entry: ['clearErrors', 'notifyDisconnect'],
106 always: [{ target: 'timedOut', in: '#online' }],
107 on: {
108 PAGE_HIDE: 'pageHidden',
109 PAGE_FREEZE: 'pageHidden',
110 },
111 },
112 pageHidden: {
113 entry: 'clearErrors',
114 on: {
115 PAGE_SHOW: 'timedOut',
116 PAGE_RESUME: 'timedOut',
60 }, 117 },
61 }, 118 },
62 socketCreated: { 119 socketCreated: {
@@ -68,6 +125,7 @@ export default createMachine(
68 initial: 'opening', 125 initial: 'opening',
69 states: { 126 states: {
70 opening: { 127 opening: {
128 always: [{ target: '#timedOut', in: '#tabHidden' }],
71 after: { 129 after: {
72 OPEN_TIMEOUT: { 130 OPEN_TIMEOUT: {
73 actions: 'raiseTimeoutError', 131 actions: 'raiseTimeoutError',
@@ -76,7 +134,7 @@ export default createMachine(
76 on: { 134 on: {
77 OPENED: { 135 OPENED: {
78 target: 'opened', 136 target: 'opened',
79 actions: ['clearError', 'notifyReconnect'], 137 actions: ['clearErrors', 'notifyReconnect'],
80 }, 138 },
81 }, 139 },
82 }, 140 },
@@ -102,48 +160,38 @@ export default createMachine(
102 }, 160 },
103 }, 161 },
104 idle: { 162 idle: {
105 initial: 'getTabState', 163 initial: 'active',
106 states: { 164 states: {
107 getTabState: {
108 always: [
109 { target: 'inactive', in: '#tabHidden' },
110 'active',
111 ],
112 },
113 active: { 165 active: {
114 on: { 166 always: [{ target: 'inactive', in: '#tabHidden' }],
115 TAB_HIDDEN: 'inactive',
116 },
117 }, 167 },
118 inactive: { 168 inactive: {
169 always: [{ target: 'active', in: '#tabVisible' }],
119 after: { 170 after: {
120 IDLE_TIMEOUT: '#timedOut', 171 IDLE_TIMEOUT: '#timedOut',
121 }, 172 },
122 on: {
123 TAB_VISIBLE: 'active',
124 },
125 }, 173 },
126 }, 174 },
127 }, 175 },
128 }, 176 },
129 on: { 177 on: {
130 CONNECT: undefined, 178 CONNECT: undefined,
131 ERROR: { 179 ERROR: { target: '#errorWait', actions: 'pushError' },
132 target: '#errorWait', 180 PAGE_HIDE: 'pageHidden',
133 actions: 'increaseRetryCount', 181 PAGE_FREEZE: 'pageHidden',
134 },
135 }, 182 },
136 }, 183 },
137 }, 184 },
138 on: { 185 on: {
139 CONNECT: { target: '.socketCreated', cond: 'hasWebSocketURL' }, 186 CONNECT: { target: '.timedOut', cond: 'hasWebSocketURL' },
140 DISCONNECT: { target: '.disconnected', actions: 'clearError' }, 187 DISCONNECT: '.disconnected',
141 }, 188 },
142 }, 189 },
143 tab: { 190 tab: {
144 initial: 'visibleOrUnknown', 191 initial: 'visibleOrUnknown',
145 states: { 192 states: {
146 visibleOrUnknown: { 193 visibleOrUnknown: {
194 id: 'tabVisible',
147 on: { 195 on: {
148 TAB_HIDDEN: 'hidden', 196 TAB_HIDDEN: 'hidden',
149 }, 197 },
@@ -156,12 +204,19 @@ export default createMachine(
156 }, 204 },
157 }, 205 },
158 }, 206 },
159 error: { 207 network: {
160 initial: 'init', 208 initial: 'onlineOrUnknown',
161 states: { 209 states: {
162 init: { 210 onlineOrUnknown: {
211 id: 'online',
163 on: { 212 on: {
164 ERROR: { actions: 'pushError' }, 213 OFFLINE: 'offline',
214 },
215 },
216 offline: {
217 id: 'offline',
218 on: {
219 ONLINE: 'onlineOrUnknown',
165 }, 220 },
166 }, 221 },
167 }, 222 },
@@ -171,12 +226,13 @@ export default createMachine(
171 { 226 {
172 guards: { 227 guards: {
173 hasWebSocketURL: ({ webSocketURL }) => webSocketURL !== undefined, 228 hasWebSocketURL: ({ webSocketURL }) => webSocketURL !== undefined,
229 needsNetwork: ({ webSocketURL }) => !isWebSocketURLLocal(webSocketURL),
174 }, 230 },
175 delays: { 231 delays: {
176 IDLE_TIMEOUT: 300_000, 232 IDLE_TIMEOUT: 300_000,
177 OPEN_TIMEOUT: 5000, 233 OPEN_TIMEOUT: 10_000,
178 PING_PERIOD: 10_000, 234 PING_PERIOD: 10_000,
179 ERROR_WAIT_TIME: ({ retryCount }) => { 235 ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => {
180 const { length } = ERROR_WAIT_TIMES; 236 const { length } = ERROR_WAIT_TIMES;
181 const index = retryCount < length ? retryCount : length - 1; 237 const index = retryCount < length ? retryCount : length - 1;
182 return ERROR_WAIT_TIMES[index]; 238 return ERROR_WAIT_TIMES[index];
@@ -191,14 +247,9 @@ export default createMachine(
191 ...context, 247 ...context,
192 errors: [...context.errors, message], 248 errors: [...context.errors, message],
193 })), 249 })),
194 increaseRetryCount: assign((context) => ({ 250 clearErrors: assign((context) => ({
195 ...context,
196 retryCount: context.retryCount + 1,
197 })),
198 clearError: assign((context) => ({
199 ...context, 251 ...context,
200 errors: [], 252 errors: [],
201 retryCount: 0,
202 })), 253 })),
203 // Workaround from https://github.com/statelyai/xstate/issues/1414#issuecomment-699972485 254 // Workaround from https://github.com/statelyai/xstate/issues/1414#issuecomment-699972485
204 raiseTimeoutError: raise({ 255 raiseTimeoutError: raise({