aboutsummaryrefslogtreecommitdiffstats
path: root/subprojects/frontend/src/xtext
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/frontend/src/xtext
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/frontend/src/xtext')
-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
3 files changed, 153 insertions, 59 deletions
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({