From 474041f105abeb75e0d0a09b16ff7e3ed6d0f682 Mon Sep 17 00:00:00 2001 From: Lee Wexler Date: Wed, 5 Jul 2023 15:23:13 -0400 Subject: [PATCH] Page state (#3409) --- CHANGELOG.md | 21 +- appcontainer/AppContainerModel.ts | 271 +++++++++++++++-- appcontainer/AppStateModel.ts | 117 ++++++++ appcontainer/PageStateModel.ts | 64 ++++ {core => appcontainer}/RouterModel.ts | 2 +- appcontainer/UserAgentModel.ts | 37 +++ appcontainer/login/LoginPanelModel.ts | 2 +- core/XH.ts | 417 ++++---------------------- core/exception/ExceptionHandler.ts | 11 +- core/index.ts | 3 +- core/{ => types}/AppState.ts | 5 + core/types/Types.ts | 27 ++ desktop/appcontainer/AppContainer.ts | 20 +- desktop/appcontainer/LockoutPanel.ts | 65 ++-- desktop/appcontainer/SuspendPanel.ts | 7 +- mobile/appcontainer/AppContainer.ts | 22 +- mobile/appcontainer/LockoutPanel.ts | 10 +- mobile/appcontainer/SuspendPanel.ts | 7 +- svc/FetchService.ts | 21 ++ svc/PrefService.ts | 10 +- 20 files changed, 683 insertions(+), 456 deletions(-) create mode 100644 appcontainer/AppStateModel.ts create mode 100644 appcontainer/PageStateModel.ts rename {core => appcontainer}/RouterModel.ts (98%) create mode 100644 appcontainer/UserAgentModel.ts rename core/{ => types}/AppState.ts (84%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2bb12771..28ce01d578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,31 +9,38 @@ with older versions of hoist-core, the new `forceReload` mode requires `hoist-core >= v16.4.0`. * Enhance `NumberFormatOptions.colorSpec` to accept custom CSS properties in addition to class names * Enhance `TabSwitcher` to allow navigation using arrow keys when focused. - * Added new option `TrackOptions.logData` to provide support for logging application data in `TrackService.` Requires hoist-core v16.4. +* New `XH.pageState` provides observable access to the current lifecycle state of the + application, allowing apps to react to changes in page visibility and focus, as well as detecting + when the browser has frozen a tab due to inactivity or navigation. + ## 57.0.0 - 2023-06-20 ### 🎁 New Features -* Enhanced the Admin alert banner feature with a new ability to save messages as presets. Useful for - standardizing alert or downtime banners, where pre-approved language can be added as a preset and - then easily loaded into a banner by members of an application support team ( +* Enhanced Admin alert banners with the ability to save messages as presets. Useful for + standardizing alert or downtime banners, where pre-approved language can be saved as a preset for + later loaded into a banner by members of an application support team ( requires `hoist-core >= v16.3.0`). * Added bindable `readonly` property to `LeftRightChooserModel`. ### ⚙️ Technical + * Support the `HOIST_IMPERSONATOR` role introduced in hoist-core `v16.3.0` * Hoist now supports and requires ag-Grid v30 or higher. This version includes critical -performance improvements to scrolling without the problematic 'ResizeObserver' issues discussed -below. + performance improvements to scrolling without the problematic 'ResizeObserver' issues discussed + below. ### 💥 Breaking Changes + * The deprecated `@settable` decorator has now been removed. Use `@bindable` instead. -* The deprecated class `@xh/hoist/admin/App` has been removed. Use `@xh/hoist/admin/AppComponent` instead. +* The deprecated class `@xh/hoist/admin/App` has been removed. Use `@xh/hoist/admin/AppComponent` + instead. ### 🐞 Bug Fixes + * Fixed a bug where Onsen components wrappers could not forward refs. * Improved the exceptions thrown by fetchService when errors occur parsing response JSON. diff --git a/appcontainer/AppContainerModel.ts b/appcontainer/AppContainerModel.ts index 232ff5c5ec..03a2d4ef37 100644 --- a/appcontainer/AppContainerModel.ts +++ b/appcontainer/AppContainerModel.ts @@ -4,9 +4,42 @@ * * Copyright © 2023 Extremely Heavy Industries Inc. */ -import {HoistModel, managed, RootRefreshContextModel, TaskObserver, XH} from '@xh/hoist/core'; +import { + AppSpec, + AppState, + createElement, + HoistAppModel, + HoistModel, + managed, + RootRefreshContextModel, + TaskObserver, + XH +} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; -import {isEmpty} from 'lodash'; +import {action, when as mobxWhen} from '@xh/hoist/mobx'; +import {wait} from '@xh/hoist/promise'; +import {createRoot} from 'react-dom/client'; +import { + AlertBannerService, + AutoRefreshService, + ChangelogService, + ConfigService, + EnvironmentService, + FetchService, + GridAutosizeService, + GridExportService, + IdentityService, + IdleService, + InspectorService, + JsonBlobService, + LocalStorageService, + PrefService, + TrackService, + WebSocketService +} from '@xh/hoist/svc'; +import {MINUTES} from '@xh/hoist/utils/datetime'; +import {checkMinVersion, throwIf} from '@xh/hoist/utils/js'; +import {compact, isEmpty} from 'lodash'; import {AboutDialogModel} from './AboutDialogModel'; import {BannerSourceModel} from './BannerSourceModel'; import {ChangelogDialogModel} from './ChangelogDialogModel'; @@ -20,16 +53,32 @@ import {ViewportSizeModel} from './ViewportSizeModel'; import {ThemeModel} from './ThemeModel'; import {ToastSourceModel} from './ToastSourceModel'; import {BannerModel} from './BannerModel'; +import {UserAgentModel} from './UserAgentModel'; +import {AppStateModel} from './AppStateModel'; +import {PageStateModel} from './PageStateModel'; +import {RouterModel} from './RouterModel'; +import {installServicesAsync} from '../core/impl/InstallServices'; +import {MIN_HOIST_CORE_VERSION} from '../core/XH'; /** * Root object for Framework GUI State. */ export class AppContainerModel extends HoistModel { + private initCalled = false; + + //--------------------------------- + // Immutable Application State + //-------------------------------- + appSpec: AppSpec = null; + appModel: HoistAppModel = null; + //------------ // Sub-models //------------ - /** Link any async operations that should mask the entire application to this model. */ @managed appLoadModel = TaskObserver.trackAll(); + @managed appStateModel = new AppStateModel(); + @managed pageStateModel = new PageStateModel(); + @managed routerModel = new RouterModel(); @managed aboutDialogModel = new AboutDialogModel(); @managed changelogDialogModel = new ChangelogDialogModel(); @@ -46,25 +95,162 @@ export class AppContainerModel extends HoistModel { @managed sizingModeModel = new SizingModeModel(); @managed viewportSizeModel = new ViewportSizeModel(); @managed themeModel = new ThemeModel(); + @managed userAgentModel = new UserAgentModel(); + + /** + * Main entry point. Initialize and render application code. + */ + renderApp(appSpec: AppSpec) { + // Remove the pre-load exception handler installed by preflight.js + window.onerror = null; + const spinner = document.getElementById('xh-preload-spinner'); + if (spinner) spinner.style.display = 'none'; + this.appSpec = appSpec instanceof AppSpec ? appSpec : new AppSpec(appSpec); + + const root = createRoot(document.getElementById('xh-root')), + rootView = createElement(appSpec.containerClass, {model: this}); + root.render(rootView); + } + + /** + * Called when application container first mounted in order to trigger initial + * authentication and initialization of framework and application. + */ + async initAsync() { + // Avoid multiple calls, which can occur if AppContainer remounted. + if (this.initCalled) return; + this.initCalled = true; + + const {appSpec} = this, + {isPhone, isTablet, isDesktop} = this.userAgentModel, + {isMobileApp} = appSpec; + + // Add xh css classes to power Hoist CSS selectors. + document.body.classList.add( + ...compact([ + 'xh-app', + isMobileApp ? 'xh-mobile' : 'xh-standard', + isDesktop ? 'xh-desktop' : null, + isPhone ? 'xh-phone' : null, + isTablet ? 'xh-tablet' : null + ]) + ); - init() { - const models = [ - this.appLoadModel, - this.aboutDialogModel, - this.changelogDialogModel, - this.exceptionDialogModel, - this.feedbackDialogModel, - this.impersonationBarModel, - this.optionsDialogModel, - this.bannerSourceModel, - this.messageSourceModel, - this.toastSourceModel, - this.refreshContextModel, - this.sizingModeModel, - this.viewportSizeModel, - this.themeModel - ]; - models.forEach((m: any) => m.init?.()); + // Disable browser context menu on long-press, used to show (app) context menus and as an + // alternate gesture for tree grid drill-own. + if (isMobileApp) { + window.addEventListener('contextmenu', e => e.preventDefault(), {capture: true}); + } + + try { + await installServicesAsync(FetchService); + this.setAppState('PRE_AUTH'); + + // consult (optional) pre-auth init for app + const modelClass: any = this.appSpec.modelClass; + await modelClass.preAuthAsync(); + + // Check if user has already been authenticated (prior login, OAuth, SSO)... + const userIsAuthenticated = await this.getAuthStatusFromServerAsync(); + + // ...if not, throw in SSO mode (unexpected error case) or trigger a login prompt. + if (!userIsAuthenticated) { + throwIf( + appSpec.isSSO, + 'Unable to complete required authentication (SSO/Oauth failure).' + ); + this.setAppState('LOGIN_REQUIRED'); + return; + } + + // ...if so, continue with initialization. + await this.completeInitAsync(); + } catch (e) { + this.setAppState('LOAD_FAILED'); + XH.handleException(e, {requireReload: true}); + } + } + + /** + * Complete initialization. Called after the client has confirmed that the user is generally + * authenticated and known to the server (regardless of application roles at this point). + */ + @action + async completeInitAsync() { + try { + // Install identity service and confirm access + await installServicesAsync(IdentityService); + if (!this.appStateModel.checkAccess()) { + this.setAppState('ACCESS_DENIED'); + return; + } + + // Complete initialization process + this.setAppState('INITIALIZING'); + await installServicesAsync([ConfigService, LocalStorageService]); + await installServicesAsync(TrackService); + await installServicesAsync([EnvironmentService, PrefService, JsonBlobService]); + + // Confirm hoist-core version after environment service loaded + const hcVersion = XH.environmentService.get('hoistCoreVersion'); + if (!checkMinVersion(hcVersion, MIN_HOIST_CORE_VERSION)) { + throw XH.exception(` + This version of Hoist React requires the server to run Hoist Core + v${MIN_HOIST_CORE_VERSION} or greater. Version ${hcVersion} detected. + `); + } + + await installServicesAsync([ + AlertBannerService, + AutoRefreshService, + ChangelogService, + IdleService, + InspectorService, + GridAutosizeService, + GridExportService, + WebSocketService + ]); + + // init all models other than Router + const models = [ + this.appLoadModel, + this.appStateModel, + this.pageStateModel, + this.routerModel, + this.aboutDialogModel, + this.changelogDialogModel, + this.exceptionDialogModel, + this.feedbackDialogModel, + this.impersonationBarModel, + this.optionsDialogModel, + this.bannerSourceModel, + this.messageSourceModel, + this.toastSourceModel, + this.refreshContextModel, + this.sizingModeModel, + this.viewportSizeModel, + this.themeModel, + this.userAgentModel + ]; + models.forEach((m: any) => m.init?.()); + + this.bindInitSequenceToAppLoadModel(); + + this.setDocTitle(); + + // Delay to workaround hot-reload styling issues in dev. + await wait(XH.isDevelopmentMode ? 300 : 1); + + const modelClass: any = this.appSpec.modelClass; + this.appModel = modelClass.instance = new modelClass(); + await this.appModel.initAsync(); + this.startRouter(); + this.startOptionsDialog(); + this.setAppState('RUNNING'); + } catch (e) { + this.setAppState('LOAD_FAILED'); + XH.handleException(e, {requireReload: true}); + } } /** @@ -105,4 +291,47 @@ export class AppContainerModel extends HoistModel { hasAboutDialog() { return !isEmpty(this.aboutDialogModel.getItems()); } + + //---------------------------- + // Implementation + //----------------------------- + private async getAuthStatusFromServerAsync(): Promise { + return await XH.fetchService + .fetchJson({ + url: 'xh/authStatus', + timeout: 3 * MINUTES // Accommodate delay for user at a credentials prompt + }) + .then(r => r.authenticated) + .catch(e => { + // 401s normal / expected for non-SSO apps when user not yet logged in. + if (e.httpStatus === 401) return false; + // Other exceptions indicate e.g. connectivity issue, server down - raise to user. + throw e; + }); + } + + private setDocTitle() { + const env = XH.getEnv('appEnvironment'), + {clientAppName} = this.appSpec; + document.title = env === 'Production' ? clientAppName : `${clientAppName} (${env})`; + } + + private startRouter() { + this.routerModel.addRoutes(this.appModel.getRoutes()); + this.routerModel.router.start(); + } + + private startOptionsDialog() { + this.optionsDialogModel.setOptions(this.appModel.getAppOptions()); + } + + private setAppState(nextState: AppState) { + this.appStateModel.setAppState(nextState); + } + + private bindInitSequenceToAppLoadModel() { + const terminalStates: AppState[] = ['RUNNING', 'SUSPENDED', 'LOAD_FAILED', 'ACCESS_DENIED'], + loadingPromise = mobxWhen(() => terminalStates.includes(this.appStateModel.state)); + loadingPromise.linkTo(this.appLoadModel); + } } diff --git a/appcontainer/AppStateModel.ts b/appcontainer/AppStateModel.ts new file mode 100644 index 0000000000..06076e4a12 --- /dev/null +++ b/appcontainer/AppStateModel.ts @@ -0,0 +1,117 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {AppState, AppSuspendData, HoistModel, XH} from '@xh/hoist/core'; +import {action, makeObservable, observable, reaction} from '@xh/hoist/mobx'; +import {Timer} from '@xh/hoist/utils/async'; +import {getClientDeviceInfo, logDebug} from '@xh/hoist/utils/js'; +import {isBoolean, isString} from 'lodash'; + +/** + * Support for Core Hoist Application state and loading. + * + * @internal + */ +export class AppStateModel extends HoistModel { + override xhImpl = true; + + @observable state: AppState = 'PRE_AUTH'; + + lastActivityMs: number = Date.now(); + suspendData: AppSuspendData; + accessDeniedMessage: string = 'Access Denied'; + + constructor() { + super(); + makeObservable(this); + this.trackLoad(); + this.createActivityListeners(); + } + + @action + setAppState(nextState: AppState) { + if (this.state !== nextState) { + logDebug(`AppState change: ${this.state} → ${nextState}`, this); + } + this.state = nextState; + } + + suspendApp(suspendData: AppSuspendData) { + if (this.state === 'SUSPENDED') return; + this.suspendData = suspendData; + this.setAppState('SUSPENDED'); + XH.webSocketService.shutdown(); + Timer.cancelAll(); + } + + checkAccess(): boolean { + const user = XH.getUser(), + {checkAccess} = XH.appSpec; + + if (isString(checkAccess)) { + if (user.hasRole(checkAccess)) return true; + this.accessDeniedMessage = `User needs the role "${checkAccess}" to access this application.`; + return false; + } else { + const ret = checkAccess(user); + if (isBoolean(ret)) return ret; + if (ret.message) { + this.accessDeniedMessage = ret.message; + } + return ret.hasAccess; + } + } + + //------------------ + // Implementation + //------------------ + private trackLoad() { + let loadStarted = window['_xhLoadTimestamp'], // set in index.html + loginStarted = null, + loginElapsed = 0; + + const disposer = reaction( + () => this.state, + state => { + const now = Date.now(); + switch (state) { + case 'RUNNING': + XH.track({ + category: 'App', + message: `Loaded ${XH.clientAppCode}`, + elapsed: now - loadStarted - loginElapsed, + data: { + appVersion: XH.appVersion, + appBuild: XH.appBuild, + locationHref: window.location.href, + ...getClientDeviceInfo() + }, + logData: ['appVersion', 'appBuild'], + omit: !XH.appSpec.trackAppLoad + }); + disposer(); + break; + case 'LOGIN_REQUIRED': + loginStarted = now; + break; + default: + if (loginStarted) loginElapsed = now - loginStarted; + } + } + ); + } + + //--------------------- + // Implementation + //--------------------- + private createActivityListeners() { + ['keydown', 'mousemove', 'mousedown', 'scroll', 'touchmove', 'touchstart'].forEach(name => { + window.addEventListener(name, () => { + this.lastActivityMs = Date.now(); + }); + }); + } +} diff --git a/appcontainer/PageStateModel.ts b/appcontainer/PageStateModel.ts new file mode 100644 index 0000000000..78050aeace --- /dev/null +++ b/appcontainer/PageStateModel.ts @@ -0,0 +1,64 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {HoistModel, PageState} from '@xh/hoist/core'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; +import {logDebug} from '@xh/hoist/utils/js'; + +/** + * Implementation of PageState maintenance. + * + * Based on https://developer.chrome.com/blog/page-lifecycle-api/ + * + * @internal + */ +export class PageStateModel extends HoistModel { + override xhImpl = true; + + @observable state: PageState; + + constructor() { + super(); + makeObservable(this); + + this.state = this.getLiveState(); + this.addListeners(); + } + + //------------------------ + // Implementation + //------------------------ + @action + private setState(nextState: PageState) { + if (this.state !== nextState) { + logDebug(`PageState change: ${this.state} → ${nextState}`, this); + } + this.state = nextState; + } + + private getLiveState(): 'hidden' | 'active' | 'passive' { + return document.visibilityState === 'hidden' + ? 'hidden' + : document.hasFocus() + ? 'active' + : 'passive'; + } + + private addListeners() { + const opts = {capture: true}; + + ['pageshow', 'focus', 'blur', 'visibilitychange', 'resume'].forEach(type => { + window.addEventListener(type, () => this.setState(this.getLiveState()), opts); + }); + + window.addEventListener('freeze', () => this.setState('frozen'), opts); + window.addEventListener( + 'pagehide', + e => this.setState(e.persisted ? 'frozen' : 'terminated'), + opts + ); + } +} diff --git a/core/RouterModel.ts b/appcontainer/RouterModel.ts similarity index 98% rename from core/RouterModel.ts rename to appcontainer/RouterModel.ts index 26e93cbcee..3ca0b2fa87 100644 --- a/core/RouterModel.ts +++ b/appcontainer/RouterModel.ts @@ -4,7 +4,7 @@ * * Copyright © 2023 Extremely Heavy Industries Inc. */ -import {HoistModel} from './'; +import {HoistModel} from '../core'; import {action, observable, makeObservable} from '@xh/hoist/mobx'; import {merge} from 'lodash'; import {isOmitted} from '@xh/hoist/utils/impl'; diff --git a/appcontainer/UserAgentModel.ts b/appcontainer/UserAgentModel.ts new file mode 100644 index 0000000000..b458a8a7b0 --- /dev/null +++ b/appcontainer/UserAgentModel.ts @@ -0,0 +1,37 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ +import {HoistModel} from '@xh/hoist/core'; +import parser from 'ua-parser-js'; + +/** + * Support for user agent parsing. + * @internal + */ +export class UserAgentModel extends HoistModel { + override xhImpl = true; + + private _uaParser: any = null; + + get isPhone(): boolean { + return this.uaParser.getDevice().type === 'mobile'; + } + + get isTablet(): boolean { + return this.uaParser.getDevice().type === 'tablet'; + } + + get isDesktop(): boolean { + return this.uaParser.getDevice().type === undefined; + } + + //--------------------- + // Implementation + //--------------------- + private get uaParser() { + return this._uaParser = this._uaParser ?? new parser(); + } +} diff --git a/appcontainer/login/LoginPanelModel.ts b/appcontainer/login/LoginPanelModel.ts index d9371ed7a4..bfa14e4d43 100644 --- a/appcontainer/login/LoginPanelModel.ts +++ b/appcontainer/login/LoginPanelModel.ts @@ -50,7 +50,7 @@ export class LoginPanelModel extends HoistModel { if (resp.success) { this.warning = ''; - await XH.completeInitAsync(); + await XH.appContainerModel.completeInitAsync(); } else { this.warning = 'Login incorrect.'; } diff --git a/core/XH.ts b/core/XH.ts index 9577e97c4f..c631f4f8e3 100644 --- a/core/XH.ts +++ b/core/XH.ts @@ -4,11 +4,12 @@ * * Copyright © 2023 Extremely Heavy Industries Inc. */ +import {RouterModel} from '@xh/hoist/appcontainer/RouterModel'; +import {Router, State} from 'router5'; import { HoistService, AppSpec, AppState, - createElement, Exception, ExceptionHandlerOptions, ExceptionHandler, @@ -17,20 +18,16 @@ import { HoistServiceClass, Theme, PlainObject, - HoistException + HoistException, + PageState, + AppSuspendData } from './'; import {Store} from '@xh/hoist/data'; import {instanceManager} from './impl/InstanceManager'; import {installServicesAsync} from './impl/InstallServices'; import {Icon} from '@xh/hoist/icon'; -import { - action, - makeObservable, - observable, - reaction as mobxReaction, - when as mobxWhen -} from '@xh/hoist/mobx'; -import {never, wait} from '@xh/hoist/promise'; +import {action} from '@xh/hoist/mobx'; +import {never} from '@xh/hoist/promise'; import { AlertBannerService, AutoRefreshService, @@ -50,28 +47,15 @@ import { WebSocketService, FetchOptions } from '@xh/hoist/svc'; -import {Timer} from '@xh/hoist/utils/async'; -import {MINUTES} from '@xh/hoist/utils/datetime'; -import {checkMinVersion, getClientDeviceInfo, throwIf} from '@xh/hoist/utils/js'; -import {camelCase, compact, flatten, isBoolean, isString, uniqueId} from 'lodash'; -import {createRoot} from 'react-dom/client'; -import parser from 'ua-parser-js'; +import {camelCase, flatten, isString, uniqueId} from 'lodash'; import {AppContainerModel} from '../appcontainer/AppContainerModel'; import {ToastModel} from '../appcontainer/ToastModel'; import {BannerModel} from '../appcontainer/BannerModel'; import '../styles/XH.scss'; import {ModelSelector, HoistModel, RefreshContextModel} from './model'; -import { - HoistAppModel, - RouterModel, - BannerSpec, - ToastSpec, - MessageSpec, - HoistUser, - TaskObserver -} from './'; +import {HoistAppModel, BannerSpec, ToastSpec, MessageSpec, HoistUser, TaskObserver} from './'; -const MIN_HOIST_CORE_VERSION = '16.0'; +export const MIN_HOIST_CORE_VERSION = '16.0'; declare const xhAppCode: string; declare const xhAppName: string; @@ -84,21 +68,16 @@ declare const xhIsDevelopmentMode: boolean; * Top-level Singleton model for Hoist. This is the main entry point for the API. * * Provides access to the built-in Hoist services, metadata about the application and environment, - * and convenience aliases to the most common framework operations. It also maintains key observable - * application state regarding dialogs, loading, and exceptions. + * and convenience aliases to the most common framework operations. * * Available via import as `XH` - also installed as `window.XH` for troubleshooting purposes. */ export class XHApi { - private _initCalled: boolean = false; - private _lastActivityMs: number = Date.now(); - private _uaParser: any = null; - - constructor() { - makeObservable(this); - this.exceptionHandler = new ExceptionHandler(); - this.bindInitSequenceToAppLoadModel(); - } + //---------------- + // Core Delegates + //---------------- + appContainerModel: AppContainerModel = new AppContainerModel(); + exceptionHandler: ExceptionHandler = new ExceptionHandler(); //---------------------------------------------------------------------------------------------- // Metadata - set via webpack.DefinePlugin at build time. @@ -152,6 +131,7 @@ export class XHApi { /** Get a reference to a singleton service by full class. */ getService(cls: HoistServiceClass): T; + /** Get a reference to a singleton service. */ getService(arg: any) { const name = isString(arg) ? arg : camelCase(arg.name); return this[name]; @@ -227,65 +207,61 @@ export class XHApi { } get isPhone(): boolean { - return this.uaParser.getDevice().type === 'mobile'; + return this.acm.userAgentModel.isPhone; } get isTablet(): boolean { - return this.uaParser.getDevice().type === 'tablet'; + return this.acm.userAgentModel.isTablet; } get isDesktop(): boolean { - return this.uaParser.getDevice().type === undefined; + return this.acm.userAgentModel.isDesktop; } - //--------------------------- - // Models - //--------------------------- - appContainerModel: AppContainerModel = new AppContainerModel(); - routerModel: RouterModel = new RouterModel(); - - //--------------------------- - // Other State - //--------------------------- - suspendData = null; - accessDeniedMessage: string = null; - exceptionHandler: ExceptionHandler = null; + /** + * The lifecycle state of the page, which changes due to changes to the focused/visible state + * of the browser tab and the browser window as a whole, as well as built-in browser behaviors + * around navigation and performance optimizations. + * + * Apps can react to this stat to pause background processes (e.g. expensive refresh + * operations) when the app is no longer visible to the user and resume them when the user + * switches back and re-activates the tab. + * + * The {@link LifeCycleState} type lists the possible states, with descriptive comments. + * See {@link https://developer.chrome.com/blog/page-lifecycle-api/} for a useful overview. + */ + get pageState(): PageState { + return this.acm.pageStateModel.state; + } - /** current lifecycle state of the application. */ - @observable - appState: AppState = 'PRE_AUTH'; + /** Current lifecycle state of the application. */ + get appState(): AppState { + return this.acm.appStateModel.state; + } - /** milliseconds timestamp at moment user activity / interaction was last detected. */ + /** Milliseconds timestamp at moment user activity / interaction was last detected. */ get lastActivityMs(): number { - return this._lastActivityMs; + return this.acm.appStateModel.lastActivityMs; } - /** true if application initialized and running (observable). */ + /** True if application initialized and running (observable). */ get appIsRunning(): boolean { return this.appState === 'RUNNING'; } - /** The currently authenticated user. */ - @observable - authUsername: string = null; - /** Root level application model. */ - appModel: HoistAppModel = null; + get appModel(): HoistAppModel { + return this.acm.appModel; + } /** Specifications for this application, provided in call to `XH.renderApp()`. */ - appSpec: AppSpec = null; + get appSpec(): AppSpec { + return this.acm.appSpec; + } /** Main entry point. Initialize and render application code. */ renderApp(appSpec: AppSpec) { - // Remove the pre-load exception handler installed by preflight.js - window.onerror = null; - const spinner = document.getElementById('xh-preload-spinner'); - if (spinner) spinner.style.display = 'none'; - this.appSpec = appSpec instanceof AppSpec ? appSpec : new AppSpec(appSpec); - - const root = createRoot(document.getElementById('xh-root')), - rootView = createElement(appSpec.containerClass, {model: this.appContainerModel}); - root.render(rootView); + this.acm.renderApp(appSpec); } /** @@ -305,14 +281,13 @@ export class XHApi { } /** - * Transition the application state. - * @internal + * Suspend all app activity and display, including timers and web sockets. + * + * Suspension is a terminal state, requiring user to reload the app. + * Used for idling, forced version upgrades, and ad-hoc killing of problematic clients. */ - @action - setAppState(appState: AppState) { - if (this.appState != appState) { - this.appState = appState; - } + suspendApp(suspendData: AppSuspendData) { + this.acm.appStateModel.suspendApp(suspendData); } /** @@ -379,14 +354,14 @@ export class XHApi { //------------------------ // Sizing Mode Support //------------------------ - setSizingMode(sizingMode: SizingMode) { - return this.acm.sizingModeModel.setSizingMode(sizingMode); - } - get sizingMode(): SizingMode { return this.acm.sizingModeModel.sizingMode; } + setSizingMode(sizingMode: SizingMode) { + return this.acm.sizingModeModel.setSizingMode(sizingMode); + } + //------------------------ // Viewport Size //------------------------ @@ -408,11 +383,15 @@ export class XHApi { //------------------------- // Routing support //------------------------- + get routerModel(): RouterModel { + return this.acm.routerModel; + } + /** * Underlying Router5 Router object implementing the routing state. * Applications should use this property to directly access the Router5 API. */ - get router() { + get router(): Router { return this.routerModel.router; } @@ -420,7 +399,7 @@ export class XHApi { * The current routing state as an observable property. * @see RoutingManager.currentState */ - get routerState() { + get routerState(): State { return this.routerModel.currentState; } @@ -671,275 +650,9 @@ export class XHApi { return uniqueId('xh-id-'); } - //--------------------------------- - // Framework Methods - //--------------------------------- - /** - * Called when application container first mounted in order to trigger initial - * authentication and initialization of framework and application. - * @internal - */ - async initAsync() { - // Avoid multiple calls, which can occur if AppContainer remounted. - if (this._initCalled) return; - this._initCalled = true; - - const {appSpec, isMobileApp, isPhone, isTablet, isDesktop, baseUrl} = this; - - if (appSpec.trackAppLoad) this.trackLoad(); - - // Add xh css classes to power Hoist CSS selectors. - document.body.classList.add( - ...compact([ - 'xh-app', - isMobileApp ? 'xh-mobile' : 'xh-standard', - isDesktop ? 'xh-desktop' : null, - isPhone ? 'xh-phone' : null, - isTablet ? 'xh-tablet' : null - ]) - ); - - this.createActivityListeners(); - - // Disable browser context menu on long-press, used to show (app) context menus and as an - // alternate gesture for tree grid drill-own. - if (isMobileApp) { - window.addEventListener('contextmenu', e => e.preventDefault(), {capture: true}); - } - - try { - await this.installServicesAsync(FetchService); - - // pre-flight allows clean recognition when we have no server. - try { - await XH.fetch({url: 'ping'}); - } catch (e) { - const pingURL = baseUrl.startsWith('http') - ? `${baseUrl}ping` - : `${window.location.origin}${baseUrl}ping`; - - throw this.exception({ - name: 'UI Server Unavailable', - detail: e.message, - message: - 'Client cannot reach UI server. Please check UI server at the ' + - `following location: ${pingURL}` - }); - } - - this.setAppState('PRE_AUTH'); - - // consult (optional) pre-auth init for app - const modelClass: any = this.appSpec.modelClass; - await modelClass.preAuthAsync(); - - // Check if user has already been authenticated (prior login, OAuth, SSO)... - const userIsAuthenticated = await this.getAuthStatusFromServerAsync(); - - // ...if not, throw in SSO mode (unexpected error case) or trigger a login prompt. - if (!userIsAuthenticated) { - throwIf( - appSpec.isSSO, - 'Unable to complete required authentication (SSO/Oauth failure).' - ); - this.setAppState('LOGIN_REQUIRED'); - return; - } - - // ...if so, continue with initialization. - await this.completeInitAsync(); - } catch (e) { - this.setAppState('LOAD_FAILED'); - this.handleException(e, {requireReload: true}); - } - } - - /** - * Complete initialization. Called after the client has confirmed that the user is generally - * authenticated and known to the server (regardless of application roles at this point). - * @internal - */ - @action - async completeInitAsync() { - try { - // Install identity service and confirm access - await this.installServicesAsync(IdentityService); - const access = this.checkAccess(); - if (!access.hasAccess) { - this.accessDeniedMessage = access.message || 'Access denied.'; - this.setAppState('ACCESS_DENIED'); - return; - } - - // Complete initialization process - this.setAppState('INITIALIZING'); - await this.installServicesAsync(ConfigService, LocalStorageService); - await this.installServicesAsync(TrackService); - await this.installServicesAsync(EnvironmentService, PrefService, JsonBlobService); - - // Confirm hoist-core version after environment service loaded - const hcVersion = XH.environmentService.get('hoistCoreVersion'); - if (!checkMinVersion(hcVersion, MIN_HOIST_CORE_VERSION)) { - throw XH.exception(` - This version of Hoist React requires the server to run Hoist Core - v${MIN_HOIST_CORE_VERSION} or greater. Version ${hcVersion} detected. - `); - } - - await this.installServicesAsync( - AlertBannerService, - AutoRefreshService, - ChangelogService, - IdleService, - InspectorService, - GridAutosizeService, - GridExportService, - WebSocketService - ); - this.acm.init(); - - this.setDocTitle(); - - // Delay to workaround hot-reload styling issues in dev. - await wait(XH.isDevelopmentMode ? 300 : 1); - - const modelClass: any = this.appSpec.modelClass; - this.appModel = modelClass.instance = new modelClass(); - await this.appModel.initAsync(); - this.startRouter(); - this.startOptionsDialog(); - this.setAppState('RUNNING'); - } catch (e) { - this.setAppState('LOAD_FAILED'); - this.handleException(e, {requireReload: true}); - } - } - - /** - * Suspend all app activity and display, including timers and web sockets. - * - * Suspension is a terminal state, requiring user to reload the app. - * Used for idling, forced version upgrades, and ad-hoc killing of problematic clients. - * @internal - */ - suspendApp(suspendData) { - if (XH.appState === 'SUSPENDED') return; - this.suspendData = suspendData; - XH.setAppState('SUSPENDED'); - XH.webSocketService.shutdown(); - Timer.cancelAll(); - } - - //------------------------ - // Implementation - //------------------------ - private checkAccess(): any { - const user = XH.getUser(), - {checkAccess} = this.appSpec; - - if (isString(checkAccess)) { - return user.hasRole(checkAccess) - ? {hasAccess: true} - : { - hasAccess: false, - message: `User needs the role "${checkAccess}" to access this application.` - }; - } else { - const ret = checkAccess(user); - return isBoolean(ret) ? {hasAccess: ret} : ret; - } - } - - private setDocTitle() { - const env = XH.getEnv('appEnvironment'), - {clientAppName} = this.appSpec; - document.title = env === 'Production' ? clientAppName : `${clientAppName} (${env})`; - } - - private async getAuthStatusFromServerAsync(): Promise { - return await this.fetchService - .fetchJson({ - url: 'xh/authStatus', - timeout: 3 * MINUTES // Accommodate delay for user at a credentials prompt - }) - .then(r => r.authenticated) - .catch(e => { - // 401s normal / expected for non-SSO apps when user not yet logged in. - if (e.httpStatus === 401) return false; - // Other exceptions indicate e.g. connectivity issue, server down - raise to user. - throw e; - }); - } - - private startRouter() { - this.routerModel.addRoutes(this.appModel.getRoutes()); - this.router.start(); - } - - private startOptionsDialog() { - this.acm.optionsDialogModel.setOptions(this.appModel.getAppOptions()); - } - private get acm(): AppContainerModel { return this.appContainerModel; } - - private bindInitSequenceToAppLoadModel() { - const terminalStates: AppState[] = ['RUNNING', 'SUSPENDED', 'LOAD_FAILED', 'ACCESS_DENIED'], - loadingPromise = mobxWhen(() => terminalStates.includes(this.appState)); - loadingPromise.linkTo(this.appLoadModel); - } - - private trackLoad() { - let loadStarted = window['_xhLoadTimestamp'], // set in index.html - loginStarted = null, - loginElapsed = 0; - - const disposer = mobxReaction( - () => this.appState, - state => { - const now = Date.now(); - switch (state) { - case 'RUNNING': - XH.track({ - category: 'App', - message: `Loaded ${this.clientAppCode}`, - elapsed: now - loadStarted - loginElapsed, - data: { - appVersion: this.appVersion, - appBuild: this.appBuild, - locationHref: window.location.href, - ...getClientDeviceInfo() - }, - logData: ['appVersion', 'appBuild'] - }); - disposer(); - break; - - case 'LOGIN_REQUIRED': - loginStarted = now; - break; - default: - if (loginStarted) loginElapsed = now - loginStarted; - } - } - ); - } - - private createActivityListeners() { - ['keydown', 'mousemove', 'mousedown', 'scroll', 'touchmove', 'touchstart'].forEach(name => { - window.addEventListener(name, () => { - this._lastActivityMs = Date.now(); - }); - }); - } - - private get uaParser() { - if (!this._uaParser) this._uaParser = new parser(); - return this._uaParser; - } - - private parseAppSpec() {} } /** app-wide singleton instance. */ diff --git a/core/exception/ExceptionHandler.ts b/core/exception/ExceptionHandler.ts index 2584b63ace..2dc3672a54 100644 --- a/core/exception/ExceptionHandler.ts +++ b/core/exception/ExceptionHandler.ts @@ -89,12 +89,6 @@ export class ExceptionHandler { timeout: 10000 }; - #isUnloading = false; - - constructor() { - window.addEventListener('unload', () => (this.#isUnloading = true)); - } - /** * Called by Hoist internally to handle exceptions, with built-in support for parsing certain * Hoist-specific exception options, displaying an appropriate error dialog to users, and @@ -116,7 +110,7 @@ export class ExceptionHandler { * @param options - provides further control over how the exception is shown and/or logged. */ handleException(exception: unknown, options?: ExceptionHandlerOptions) { - if (this.#isUnloading) return; + if (XH.pageState == 'terminated' || XH.pageState == 'frozen') return; const {e, opts} = this.parseArgs(exception, options); @@ -156,7 +150,8 @@ export class ExceptionHandler { * @param options - provides further control over how the exception is shown and/or logged. */ showException(exception: unknown, options?: ExceptionHandlerOptions) { - if (this.#isUnloading) return; + if (XH.pageState == 'terminated' || XH.pageState == 'frozen') return; + const {e, opts} = this.parseArgs(exception, options); XH.appContainerModel.exceptionDialogModel.show(e, opts); } diff --git a/core/index.ts b/core/index.ts index 94fd7070a4..2bb07f53bd 100644 --- a/core/index.ts +++ b/core/index.ts @@ -3,6 +3,7 @@ export * from './enums/RenderMode'; export * from './enums/SizingMode'; export * from './types/Interfaces'; export * from './types/Types'; +export * from './types/AppState'; export * from './elem'; export * from './persist/'; export * from './TaskObserver'; @@ -13,12 +14,10 @@ export * from './load'; export * from './model'; export * from './HoistService'; -export * from './AppState'; export * from './AppSpec'; export * from './HoistProps'; export * from './HoistComponent'; export * from './RefreshContextView'; -export * from './RouterModel'; export * from './HoistAppModel'; export * from './exception/ExceptionHandler'; diff --git a/core/AppState.ts b/core/types/AppState.ts similarity index 84% rename from core/AppState.ts rename to core/types/AppState.ts index 24bd46e86a..7df6d15088 100644 --- a/core/AppState.ts +++ b/core/types/AppState.ts @@ -20,3 +20,8 @@ export const AppState = Object.freeze({ // eslint-disable-next-line export type AppState = (typeof AppState)[keyof typeof AppState]; + +export interface AppSuspendData { + message?: string; + reason: 'IDLE' | 'SERVER_FORCE' | 'APP_UPDATE'; +} diff --git a/core/types/Types.ts b/core/types/Types.ts index 239ed7d2ca..36f685ecf5 100644 --- a/core/types/Types.ts +++ b/core/types/Types.ts @@ -58,3 +58,30 @@ export type Content = | (() => ReactElement); export type DateLike = Date | LocalDate | MomentInput; + +export type PageState = + /** + * Window/tab is visible and focused. + */ + | 'active' + /** + * Window/tab is visible but not focused - i.e. the browser is visible on the screen and this + * tab is active, but another application in the OS is currently focused, or the user is + * interacting with controls in the browser outside of this page, like the URL bar. + */ + | 'passive' + /** + * Window/tab is not visible - browser is either on another tab within the same window, or the + * entire browser is minimized or hidden behind another application in the OS. + */ + | 'hidden' + /** + * Page has been frozen by the browser due to inactivity (as a perf/memory/power optimization) + * or because the user has navigated away and the page is in the back/forward cache (but not + * yet completely unloaded / terminated). + */ + | 'frozen' + /** + * The page is in the process of being unloaded by the browser (this is a terminal state x_x). + */ + | 'terminated'; diff --git a/desktop/appcontainer/AppContainer.ts b/desktop/appcontainer/AppContainer.ts index fac14f686c..4f27c2f774 100644 --- a/desktop/appcontainer/AppContainer.ts +++ b/desktop/appcontainer/AppContainer.ts @@ -68,8 +68,8 @@ export const AppContainer = hoistCmp({ displayName: 'AppContainer', model: uses(AppContainerModel), - render() { - useOnMount(() => XH.initAsync()); + render({model}) { + useOnMount(() => model.initAsync()); return fragment( hotkeysProvider( @@ -106,10 +106,10 @@ function viewForState() { } } -const lockoutView = hoistCmp.factory({ +const lockoutView = hoistCmp.factory({ displayName: 'LockoutView', - render() { - const content = XH.appSpec.lockoutPanel ?? lockoutPanel; + render({model}) { + const content = model.appSpec.lockoutPanel ?? lockoutPanel; return elementFromContent(content); } }); @@ -119,7 +119,7 @@ const appContainerView = hoistCmp.factory({ model: uses(AppContainerModel), render({model}) { - const {appSpec, appModel} = XH; + const {appSpec, appModel} = model; let ret: ReactElement = viewport( vframe( impersonationBar(), @@ -152,10 +152,10 @@ const appLoadMask = hoistCmp.factory(({model}) => mask({bind: model.appLoadModel, spinner: true}) ); -const suspendedView = hoistCmp.factory({ - render() { - if (XH.suspendData?.reason === 'IDLE') { - const content = XH.appSpec.idlePanel ?? idlePanel; +const suspendedView = hoistCmp.factory({ + render({model}) { + if (model.appStateModel.suspendData?.reason === 'IDLE') { + const content = model.appSpec.idlePanel ?? idlePanel; return elementFromContent(content, {onReactivate: () => XH.reloadApp()}); } return suspendPanel(); diff --git a/desktop/appcontainer/LockoutPanel.ts b/desktop/appcontainer/LockoutPanel.ts index fb7120fe28..8f06356b94 100644 --- a/desktop/appcontainer/LockoutPanel.ts +++ b/desktop/appcontainer/LockoutPanel.ts @@ -35,36 +35,39 @@ export const lockoutPanel = hoistCmp.factory({ } }); -function unauthorizedMessage() { - const {appSpec, identityService} = XH, - user = XH.getUser(), - roleMsg = isEmpty(user.roles) - ? 'no roles assigned' - : `the roles [${user.roles.join(', ')}]`; +const unauthorizedMessage = hoistCmp.factory({ + render({model}) { + const {identityService} = XH, + {appSpec, appStateModel} = model, + user = XH.getUser(), + roleMsg = isEmpty(user.roles) + ? 'no roles assigned' + : `the roles [${user.roles.join(', ')}]`; - return div( - p(XH.accessDeniedMessage ?? ''), - p(`You are logged in as ${user.username} and have ${roleMsg}.`), - p({ - item: appSpec.lockoutMessage, - omit: !appSpec.lockoutMessage - }), - hbox( - filler(), - logoutButton({ - text: 'Logout', - intent: null, - minimal: false - }), - hspacer(5), - button({ - omit: !identityService.isImpersonating, - icon: Icon.impersonate(), - text: 'End Impersonation', - minimal: false, - onClick: () => identityService.endImpersonateAsync() + return div( + p(appStateModel.accessDeniedMessage ?? ''), + p(`You are logged in as ${user.username} and have ${roleMsg}.`), + p({ + item: appSpec.lockoutMessage, + omit: !appSpec.lockoutMessage }), - filler() - ) - ); -} + hbox( + filler(), + logoutButton({ + text: 'Logout', + intent: null, + minimal: false + }), + hspacer(5), + button({ + omit: !identityService.isImpersonating, + icon: Icon.impersonate(), + text: 'End Impersonation', + minimal: false, + onClick: () => identityService.endImpersonateAsync() + }), + filler() + ) + ); + } +}); diff --git a/desktop/appcontainer/SuspendPanel.ts b/desktop/appcontainer/SuspendPanel.ts index 0680f48af0..4b25492b50 100644 --- a/desktop/appcontainer/SuspendPanel.ts +++ b/desktop/appcontainer/SuspendPanel.ts @@ -4,6 +4,7 @@ * * Copyright © 2023 Extremely Heavy Industries Inc. */ +import {AppContainerModel} from '@xh/hoist/appcontainer/AppContainerModel'; import {hoistCmp, XH} from '@xh/hoist/core'; import {viewport, div, p, filler} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -16,11 +17,11 @@ import './SuspendPanel.scss'; * Generic Panel to display when the app is suspended. * @internal */ -export const suspendPanel = hoistCmp.factory({ +export const suspendPanel = hoistCmp.factory({ displayName: 'SuspendPanel', - render() { - const message = XH.suspendData?.message; + render({model}) { + const message = model.appStateModel.suspendData?.message; return viewport({ alignItems: 'center', justifyContent: 'center', diff --git a/mobile/appcontainer/AppContainer.ts b/mobile/appcontainer/AppContainer.ts index 643091f937..a60a598a28 100644 --- a/mobile/appcontainer/AppContainer.ts +++ b/mobile/appcontainer/AppContainer.ts @@ -53,8 +53,8 @@ export const AppContainer = hoistCmp({ displayName: 'AppContainer', model: uses(AppContainerModel), - render() { - useOnMount(() => XH.initAsync()); + render({model}) { + useOnMount(() => model.initAsync()); return fragment( errorBoundary(viewForState()), @@ -89,10 +89,10 @@ function viewForState() { } } -const lockoutView = hoistCmp.factory({ +const lockoutView = hoistCmp.factory({ displayName: 'LockoutView', - render() { - const content = XH.appSpec.lockoutPanel ?? lockoutPanel; + render({model}) { + const content = model.appSpec.lockoutPanel ?? lockoutPanel; return elementFromContent(content); } }); @@ -107,7 +107,9 @@ const appContainerView = hoistCmp.factory({ bannerList(), refreshContextView({ model: model.refreshContextModel, - item: frame(createElement(XH.appSpec.componentClass, {model: XH.appModel})) + item: frame( + createElement(model.appSpec.componentClass, {model: model.appModel}) + ) }), versionBar() ), @@ -133,10 +135,10 @@ const bannerList = hoistCmp.factory({ } }); -const suspendedView = hoistCmp.factory({ - render() { - if (XH.suspendData?.reason === 'IDLE') { - const content = XH.appSpec.idlePanel ?? idlePanel; +const suspendedView = hoistCmp.factory({ + render({model}) { + if (model.appStateModel.suspendData?.reason === 'IDLE') { + const content = model.appSpec.idlePanel ?? idlePanel; return elementFromContent(content, {onReactivate: () => XH.reloadApp()}); } return suspendPanel(); diff --git a/mobile/appcontainer/LockoutPanel.ts b/mobile/appcontainer/LockoutPanel.ts index 1c2a9260a2..7c79efd8a2 100644 --- a/mobile/appcontainer/LockoutPanel.ts +++ b/mobile/appcontainer/LockoutPanel.ts @@ -4,6 +4,7 @@ * * Copyright © 2023 Extremely Heavy Industries Inc. */ +import {AppContainerModel} from '@xh/hoist/appcontainer/AppContainerModel'; import {div, vspacer} from '@xh/hoist/cmp/layout'; import {hoistCmp, XH} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; @@ -16,17 +17,18 @@ import './LockoutPanel.scss'; * * @internal */ -export const lockoutPanel = hoistCmp.factory({ +export const lockoutPanel = hoistCmp.factory({ displayName: 'LockoutPanel', - render() { + render({model}) { const user = XH.getUser(), - {appSpec, identityService} = XH; + {appSpec, appStateModel} = model, + {identityService} = XH; return page( div({ className: 'xh-lockout-panel', item: div( - XH.accessDeniedMessage ?? '', + appStateModel.accessDeniedMessage ?? '', vspacer(10), `You are logged in as ${user.username} and have the roles [${ user.roles.join(', ') || '--' diff --git a/mobile/appcontainer/SuspendPanel.ts b/mobile/appcontainer/SuspendPanel.ts index c92571a46a..f7162d7259 100644 --- a/mobile/appcontainer/SuspendPanel.ts +++ b/mobile/appcontainer/SuspendPanel.ts @@ -5,6 +5,7 @@ * Copyright © 2023 Extremely Heavy Industries Inc. */ +import {AppContainerModel} from '@xh/hoist/appcontainer/AppContainerModel'; import {XH, hoistCmp} from '@xh/hoist/core'; import {vframe, div, p} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/mobile/cmp/panel'; @@ -17,11 +18,11 @@ import './SuspendPanel.scss'; * Generic Panel to display when the app is suspended. * @internal */ -export const suspendPanel = hoistCmp.factory({ +export const suspendPanel = hoistCmp.factory({ displayName: 'SuspendPanel', - render() { - const message = XH.suspendData?.message; + render({model}) { + const message = model.appStateModel.suspendData?.message; return panel({ className: 'xh-suspend-panel', title: `Reload Required`, diff --git a/svc/FetchService.ts b/svc/FetchService.ts index dec913bc69..563b46de8d 100644 --- a/svc/FetchService.ts +++ b/svc/FetchService.ts @@ -41,6 +41,27 @@ export class FetchService extends HoistService { defaultHeaders = {}; defaultTimeout = (30 * SECONDS) as any; + override async initAsync() { + // pre-flight to allows clean recognition when we have no server. + try { + await this.fetch({url: 'ping'}); + } catch (e) { + const {baseUrl} = XH, + pingURL = baseUrl.startsWith('http') + ? `${baseUrl}ping` + : `${window.location.origin}${baseUrl}ping`; + + throw XH.exception({ + name: 'UI Server Unavailable', + detail: e.message, + message: + 'Client cannot reach UI server. Please check UI server at the ' + + `following location: ${pingURL}`, + logOnServer: false + }); + } + } + /** * Set default headers to be sent with all subsequent requests. * @param headers - to be sent with all fetch requests, or a function to generate. diff --git a/svc/PrefService.ts b/svc/PrefService.ts index 7878717e6f..21baa2adfb 100644 --- a/svc/PrefService.ts +++ b/svc/PrefService.ts @@ -34,9 +34,13 @@ export class PrefService extends HoistService { constructor() { super(); - const pushFn = () => this.pushPendingAsync(); - window.addEventListener('beforeunload', pushFn); - this.pushPendingBuffered = debounce(pushFn, 5 * SECONDS); + this.addReaction({ + track: () => XH.pageState, + run: state => { + if (state != 'active') this.pushPendingAsync(); + } + }); + this.pushPendingBuffered = debounce(() => this.pushPendingAsync(), 5 * SECONDS); } override async initAsync() {