From 292e8998f3e7d106a91954e345a70f4cf3a317a8 Mon Sep 17 00:00:00 2001 From: Kristóf Marussy Date: Tue, 6 Sep 2022 11:30:32 +0200 Subject: 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). --- .../src/editor/ConnectionStatusNotification.tsx | 143 ++++++++++++++++----- subprojects/frontend/src/editor/EditorStore.ts | 9 ++ subprojects/frontend/src/xtext/XtextClient.ts | 4 + .../frontend/src/xtext/XtextWebSocketClient.ts | 79 +++++++++--- subprojects/frontend/src/xtext/webSocketMachine.ts | 129 +++++++++++++------ 5 files changed, 274 insertions(+), 90 deletions(-) (limited to 'subprojects') 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 @@ import Button from '@mui/material/Button'; import { observer } from 'mobx-react-lite'; -import { type SnackbarKey, useSnackbar } from 'notistack'; +import { + useSnackbar, + type SnackbarKey, + type SnackbarMessage, + type OptionsObject, +} from 'notistack'; import React, { useEffect } from 'react'; import { ContrastThemeProvider } from '../theme/ThemeProvider'; import type EditorStore from './EditorStore'; -const CONNECTING_DEBOUNCE_TIMEOUT = 250; +const DEBOUNCE_TIMEOUT = 350; + +function enqueueLater( + enqueueSnackbar: ( + message: SnackbarMessage, + options: OptionsObject | undefined, + ) => SnackbarKey, + closeSnackbar: (key: SnackbarKey) => void, + message: SnackbarMessage, + options?: OptionsObject | undefined, + debounceTimeout = DEBOUNCE_TIMEOUT, +): () => void { + let key: SnackbarKey | undefined; + let timeout: number | undefined = setTimeout(() => { + timeout = undefined; + key = enqueueSnackbar(message, options); + }, debounceTimeout); + return () => { + if (timeout !== undefined) { + clearTimeout(timeout); + } + if (key !== undefined) { + closeSnackbar(key); + } + }; +} export default observer(function ConnectionStatusNotification({ editorStore, }: { editorStore: EditorStore; }): null { - const { opened, opening, connectionErrors } = editorStore; + const { + opened, + opening, + connectionErrors, + disconnectedByUser, + networkMissing, + } = editorStore; const { enqueueSnackbar, closeSnackbar } = useSnackbar(); useEffect(() => { if (opening) { - let key: SnackbarKey | undefined; - let timeout: number | undefined = setTimeout(() => { - timeout = undefined; - key = enqueueSnackbar('Connecting to Refinery', { + return enqueueLater( + enqueueSnackbar, + closeSnackbar, + 'Connecting to Refinery', + { persist: true, action: ( ), - }); - }, CONNECTING_DEBOUNCE_TIMEOUT); - return () => { - if (timeout !== undefined) { - clearTimeout(timeout); - } - if (key !== undefined) { - closeSnackbar(key); - } - }; + }, + 500, + ); } - if (connectionErrors.length >= 1) { - const key = enqueueSnackbar( + if (connectionErrors.length >= 1 && !opening) { + return enqueueLater( + enqueueSnackbar, + closeSnackbar,
- Connection error: {connectionErrors[0]} + Connection error:{' '} + {connectionErrors[connectionErrors.length - 1]} {connectionErrors.length >= 2 && ( <> {' '} @@ -57,28 +90,72 @@ export default observer(function ConnectionStatusNotification({ persist: !opened, variant: 'error', action: opened ? ( - - - + ) : ( - + <> + + ), + }, + ); + } + + if (networkMissing) { + if (disconnectedByUser) { + return enqueueLater( + enqueueSnackbar, + closeSnackbar, +
+ No network connection: Some editing features might be + degraded +
, + { + action: ( + + + + ), + }, + 0, + ); + } + + return enqueueLater( + enqueueSnackbar, + closeSnackbar, +
+ No network connection: Refinery will try to reconnect when the + connection is restored +
, + { + persist: true, + action: ( + + + ), }, ); - return () => closeSnackbar(key); } - if (!opened) { - const key = enqueueSnackbar( + if (disconnectedByUser) { + return enqueueLater( + enqueueSnackbar, + closeSnackbar,
Not connected to Refinery: Some editing features might be degraded @@ -86,12 +163,14 @@ export default observer(function ConnectionStatusNotification({ { action: ( - + ), }, + 0, ); - return () => closeSnackbar(key); } return () => {}; @@ -100,6 +179,8 @@ export default observer(function ConnectionStatusNotification({ opened, opening, connectionErrors, + disconnectedByUser, + networkMissing, closeSnackbar, enqueueSnackbar, ]); 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 { contentAssist: false, formatText: false, }); + this.client.start(); } get opened(): boolean { @@ -77,6 +78,14 @@ export default class EditorStore { return this.client.webSocketClient.opening; } + get disconnectedByUser(): boolean { + return this.client.webSocketClient.disconnectedByUser; + } + + get networkMissing(): boolean { + return this.client.webSocketClient.networkMissing; + } + get connectionErrors(): string[] { return this.client.webSocketClient.errors; } 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 { this.occurrencesService = new OccurrencesService(store, this.updateService); } + start(): void { + this.webSocketClient.start(); + } + private onReconnect(): void { this.updateService.onReconnect(); 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'; import PendingTask from '../utils/PendingTask'; import getLogger from '../utils/getLogger'; -import webSocketMachine from './webSocketMachine'; +import webSocketMachine, { isWebSocketURLLocal } from './webSocketMachine'; import { type XtextWebPushService, XtextResponse, @@ -16,7 +16,9 @@ import { PongResult } from './xtextServiceResults'; const XTEXT_SUBPROTOCOL_V1 = 'tools.refinery.language.web.xtext.v1'; -const REQUEST_TIMEOUT = 1000; +// Use a large enough timeout so that a request can complete successfully +// even if the browser has throttled the background tab. +const REQUEST_TIMEOUT = 5000; const log = getLogger('xtext.XtextWebSocketClient'); @@ -52,6 +54,7 @@ export default class XtextWebSocketClient { openWebSocket: ({ webSocketURL }) => this.openWebSocket(webSocketURL), closeWebSocket: () => this.closeWebSocket(), notifyReconnect: () => this.onReconnect(), + notifyDisconnect: () => this.onDisconnect(), cancelPendingRequests: () => this.cancelPendingRequests(), }, services: { @@ -141,20 +144,6 @@ export default class XtextWebSocketClient { private readonly onDisconnect: DisconnectHandler, private readonly onPush: PushHandler, ) { - this.interpreter - .onTransition((state, event) => { - log.trace('WebSocke state transition', state.value, 'on event', event); - this.stateAtom.reportChanged(); - }) - .start(); - - this.updateVisibility(); - document.addEventListener('visibilitychange', () => - this.updateVisibility(), - ); - - this.interpreter.send('CONNECT'); - makeAutoObservable< XtextWebSocketClient, | 'stateAtom' @@ -177,6 +166,40 @@ export default class XtextWebSocketClient { }); } + start(): void { + this.interpreter + .onTransition((state, event) => { + log.trace('WebSocke state transition', state.value, 'on event', event); + this.stateAtom.reportChanged(); + }) + .start(); + + this.interpreter.send(window.navigator.onLine ? 'ONLINE' : 'OFFLINE'); + window.addEventListener('offline', () => this.interpreter.send('OFFLINE')); + window.addEventListener('online', () => this.interpreter.send('ONLINE')); + this.updateVisibility(); + document.addEventListener('visibilitychange', () => + this.updateVisibility(), + ); + window.addEventListener('pagehide', () => + this.interpreter.send('PAGE_HIDE'), + ); + window.addEventListener('pageshow', () => { + this.updateVisibility(); + this.interpreter.send('PAGE_SHOW'); + }); + // https://developer.chrome.com/blog/page-lifecycle-api/#new-features-added-in-chrome-68 + if ('wasDiscarded' in document) { + document.addEventListener('freeze', () => + this.interpreter.send('PAGE_FREEZE'), + ); + document.addEventListener('resume', () => + this.interpreter.send('PAGE_RESUME'), + ); + } + this.interpreter.send('CONNECT'); + } + get state() { this.stateAtom.reportObserved(); return this.interpreter.state; @@ -190,6 +213,19 @@ export default class XtextWebSocketClient { return this.state.matches('connection.socketCreated.open.opened'); } + get disconnectedByUser(): boolean { + return this.state.matches('connection.disconnected'); + } + + get networkMissing(): boolean { + return ( + this.state.matches('connection.temporarilyOffline') || + (this.disconnectedByUser && + this.state.matches('network.offline') && + !isWebSocketURLLocal(this.state.context.webSocketURL)) + ); + } + get errors(): string[] { return this.state.context.errors; } @@ -217,9 +253,13 @@ export default class XtextWebSocketClient { const id = nanoid(); const promise = new Promise((resolve, reject) => { - const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => - this.removeTask(id), - ); + const task = new PendingTask(resolve, reject, REQUEST_TIMEOUT, () => { + this.interpreter.send({ + type: 'ERROR', + message: 'Connection timed out', + }); + this.removeTask(id); + }); this.pendingRequests.set(id, task); }); @@ -272,7 +312,6 @@ export default class XtextWebSocketClient { } private cancelPendingRequests(): void { - this.onDisconnect(); this.pendingRequests.forEach((task) => task.reject(new CancelledError('Closing connection')), ); 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]; export interface WebSocketContext { webSocketURL: string | undefined; errors: string[]; - retryCount: number; } export type WebSocketEvent = @@ -17,8 +16,33 @@ export type WebSocketEvent = | { type: 'OPENED' } | { type: 'TAB_VISIBLE' } | { type: 'TAB_HIDDEN' } + | { type: 'PAGE_HIDE' } + | { type: 'PAGE_SHOW' } + | { type: 'PAGE_FREEZE' } + | { type: 'PAGE_RESUME' } + | { type: 'ONLINE' } + | { type: 'OFFLINE' } | { type: 'ERROR'; message: string }; +export function isWebSocketURLLocal(webSocketURL: string | undefined): boolean { + if (webSocketURL === undefined) { + return false; + } + let hostname: string; + try { + ({ hostname } = new URL(webSocketURL)); + } catch { + return false; + } + // https://stackoverflow.com/a/57949518 + return ( + hostname === 'localhost' || + hostname === '[::1]' || + hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) !== + null + ); +} + export default createMachine( { id: 'webSocket', @@ -31,32 +55,65 @@ export default createMachine( context: { webSocketURL: undefined, errors: [], - retryCount: 0, }, type: 'parallel', states: { connection: { initial: 'disconnected', + entry: 'clearErrors', states: { disconnected: { id: 'disconnected', + entry: ['clearErrors', 'notifyDisconnect'], on: { CONFIGURE: { actions: 'configure' }, }, }, timedOut: { id: 'timedOut', + always: [ + { + target: 'temporarilyOffline', + cond: 'needsNetwork', + in: '#offline', + }, + { target: 'socketCreated', in: '#tabVisible' }, + ], on: { - TAB_VISIBLE: 'socketCreated', + PAGE_HIDE: 'pageHidden', + PAGE_FREEZE: 'pageHidden', }, }, errorWait: { id: 'errorWait', + always: [ + { + target: 'temporarilyOffline', + cond: 'needsNetwork', + in: '#offline', + }, + ], after: { - ERROR_WAIT_TIME: [ - { target: 'timedOut', in: '#tabHidden' }, - { target: 'socketCreated' }, - ], + ERROR_WAIT_TIME: 'timedOut', + }, + on: { + PAGE_HIDE: 'pageHidden', + PAGE_FREEZE: 'pageHidden', + }, + }, + temporarilyOffline: { + entry: ['clearErrors', 'notifyDisconnect'], + always: [{ target: 'timedOut', in: '#online' }], + on: { + PAGE_HIDE: 'pageHidden', + PAGE_FREEZE: 'pageHidden', + }, + }, + pageHidden: { + entry: 'clearErrors', + on: { + PAGE_SHOW: 'timedOut', + PAGE_RESUME: 'timedOut', }, }, socketCreated: { @@ -68,6 +125,7 @@ export default createMachine( initial: 'opening', states: { opening: { + always: [{ target: '#timedOut', in: '#tabHidden' }], after: { OPEN_TIMEOUT: { actions: 'raiseTimeoutError', @@ -76,7 +134,7 @@ export default createMachine( on: { OPENED: { target: 'opened', - actions: ['clearError', 'notifyReconnect'], + actions: ['clearErrors', 'notifyReconnect'], }, }, }, @@ -102,48 +160,38 @@ export default createMachine( }, }, idle: { - initial: 'getTabState', + initial: 'active', states: { - getTabState: { - always: [ - { target: 'inactive', in: '#tabHidden' }, - 'active', - ], - }, active: { - on: { - TAB_HIDDEN: 'inactive', - }, + always: [{ target: 'inactive', in: '#tabHidden' }], }, inactive: { + always: [{ target: 'active', in: '#tabVisible' }], after: { IDLE_TIMEOUT: '#timedOut', }, - on: { - TAB_VISIBLE: 'active', - }, }, }, }, }, on: { CONNECT: undefined, - ERROR: { - target: '#errorWait', - actions: 'increaseRetryCount', - }, + ERROR: { target: '#errorWait', actions: 'pushError' }, + PAGE_HIDE: 'pageHidden', + PAGE_FREEZE: 'pageHidden', }, }, }, on: { - CONNECT: { target: '.socketCreated', cond: 'hasWebSocketURL' }, - DISCONNECT: { target: '.disconnected', actions: 'clearError' }, + CONNECT: { target: '.timedOut', cond: 'hasWebSocketURL' }, + DISCONNECT: '.disconnected', }, }, tab: { initial: 'visibleOrUnknown', states: { visibleOrUnknown: { + id: 'tabVisible', on: { TAB_HIDDEN: 'hidden', }, @@ -156,12 +204,19 @@ export default createMachine( }, }, }, - error: { - initial: 'init', + network: { + initial: 'onlineOrUnknown', states: { - init: { + onlineOrUnknown: { + id: 'online', on: { - ERROR: { actions: 'pushError' }, + OFFLINE: 'offline', + }, + }, + offline: { + id: 'offline', + on: { + ONLINE: 'onlineOrUnknown', }, }, }, @@ -171,12 +226,13 @@ export default createMachine( { guards: { hasWebSocketURL: ({ webSocketURL }) => webSocketURL !== undefined, + needsNetwork: ({ webSocketURL }) => !isWebSocketURLLocal(webSocketURL), }, delays: { IDLE_TIMEOUT: 300_000, - OPEN_TIMEOUT: 5000, + OPEN_TIMEOUT: 10_000, PING_PERIOD: 10_000, - ERROR_WAIT_TIME: ({ retryCount }) => { + ERROR_WAIT_TIME: ({ errors: { length: retryCount } }) => { const { length } = ERROR_WAIT_TIMES; const index = retryCount < length ? retryCount : length - 1; return ERROR_WAIT_TIMES[index]; @@ -191,14 +247,9 @@ export default createMachine( ...context, errors: [...context.errors, message], })), - increaseRetryCount: assign((context) => ({ - ...context, - retryCount: context.retryCount + 1, - })), - clearError: assign((context) => ({ + clearErrors: assign((context) => ({ ...context, errors: [], - retryCount: 0, })), // Workaround from https://github.com/statelyai/xstate/issues/1414#issuecomment-699972485 raiseTimeoutError: raise({ -- cgit v1.2.3-54-g00ecf