diff --git a/src/app/common/application.ts b/src/app/common/application.ts index a1f8af5c7..d7589e273 100644 --- a/src/app/common/application.ts +++ b/src/app/common/application.ts @@ -6,6 +6,8 @@ import { log } from './general'; let _timer: ReturnType; let _last_chunk_load_reload_key = ''; const CHUNK_LOAD_RELOAD_KEY = 'BACKOFFICE.chunk_load_reload'; +/** Chunk load errors that the router error handler already handled */ +const _handled_chunk_errors = new WeakSet(); export const updateAvailable = signal(false); @@ -55,11 +57,16 @@ export function isChunkLoadError(error: unknown): boolean { return false; } +/** + * Router navigation error handler. Reloads the application once when a lazy + * route chunk fails to load. Returns true when the error is a chunk load error. + */ export function reloadApplicationOnChunkLoadError( error: unknown, reload: () => void = () => location.reload(), ): boolean { if (!isChunkLoadError(error)) return false; + if (error && typeof error === 'object') _handled_chunk_errors.add(error); const reload_key = getChunkLoadReloadKey(error); if (getLastChunkLoadReloadKey() === reload_key) { @@ -71,6 +78,17 @@ export function reloadApplicationOnChunkLoadError( return true; } +/** + * True for a chunk load error that `reloadApplicationOnChunkLoadError` handled. + * Angular still sends failed initial and back/forward navigations to the + * ErrorHandler after the router error handler runs. + */ +export function isHandledChunkLoadError(error: unknown): boolean { + return ( + !!error && typeof error === 'object' && _handled_chunk_errors.has(error) + ); +} + /** * Notify the user when a new version is available. * diff --git a/src/app/common/notifications.ts b/src/app/common/notifications.ts index 31d7f4a20..12c33d4dd 100644 --- a/src/app/common/notifications.ts +++ b/src/app/common/notifications.ts @@ -1,5 +1,6 @@ import type { MatSnackBar } from '@angular/material/snack-bar'; +import { log } from './general'; import { ApplicationIcon } from './types'; /** Snackbar service, or a promise of it while the snackbar module loads */ @@ -9,13 +10,25 @@ let _service: MatSnackBar | Promise | null = null; * Set the snackbar used for notifications. * Pass a promise to load the snackbar module lazily. * Notifications made before it resolves open once it does. + * If the promise rejects (e.g. its chunk fails to load on a flaky network), + * those notifications are dropped with a warning. */ export function setNotifyOutlet(snackbar: MatSnackBar | Promise) { _service = snackbar; if (snackbar instanceof Promise) { - snackbar.then((service) => { - if (_service === snackbar) _service = service; - }); + snackbar.then( + (service) => { + if (_service === snackbar) _service = service; + }, + (error) => + log( + 'Notify', + 'Failed to load the snackbar', + [error], + 'warn', + true, + ), + ); } } @@ -41,8 +54,11 @@ export function notify( throw new Error("Snackbar service hasn't been initialised"); } if (_service instanceof Promise) { - _service.then((service) => - openNotification(service, type, message, action, on_action), + // setNotifyOutlet logs a failed load, so ignore the rejection here + _service.then( + (service) => + openNotification(service, type, message, action, on_action), + () => undefined, ); return; } diff --git a/src/app/common/sentry.ts b/src/app/common/sentry.ts index 798e4c3c6..78de1c32e 100644 --- a/src/app/common/sentry.ts +++ b/src/app/common/sentry.ts @@ -2,6 +2,7 @@ import { ErrorHandler, Injectable, Injector, isDevMode } from '@angular/core'; import { authority, isMock } from '@placeos/ts-client'; import { VERSION } from '../../env/version'; +import { isHandledChunkLoadError } from './application'; import { log } from './general'; /** DSN for deployments that do not set `config.backoffice.sentry_dsn` */ @@ -43,12 +44,15 @@ function isMonacoCancellation(error: unknown) { /** * Angular error handler. Logs errors to the console as the default handler - * does, and sends them to Sentry. Ignores Monaco cancellations. + * does, and sends them to Sentry. Ignores Monaco cancellations and lazy route + * chunk failures that the router error handler already handled. */ @Injectable() export class SentryReportingErrorHandler implements ErrorHandler { public handleError(error: unknown) { - if (isMonacoCancellation(error)) return; + if (isMonacoCancellation(error) || isHandledChunkLoadError(error)) { + return; + } console.error(error); reportError(error); } diff --git a/src/tests/common/notifications.spec.ts b/src/tests/common/notifications.spec.ts index 18b4cf587..ca8f28bf8 100644 --- a/src/tests/common/notifications.spec.ts +++ b/src/tests/common/notifications.spec.ts @@ -82,6 +82,18 @@ describe('notifications.ts', () => { expect.any(Object), ); }); + + it('should not raise an unhandled rejection when the lazy snackbar fails to load', async () => { + const failed = Promise.reject( + new TypeError('Importing a module script failed.'), + ); + setNotifyOutlet(failed as any); + notify('info', 'Lost message'); + await failed.catch(() => undefined); + await new Promise((resolve) => setTimeout(resolve)); + expect(mock_snackbar.open).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + }); }); describe('notify', () => { diff --git a/src/tests/common/sentry.spec.ts b/src/tests/common/sentry.spec.ts index f3be3fe25..acd4e15b7 100644 --- a/src/tests/common/sentry.spec.ts +++ b/src/tests/common/sentry.spec.ts @@ -82,6 +82,25 @@ describe('sentry', () => { console_error.mockRestore(); }); + it('ignores chunk load errors the router already handled', async () => { + const { initSentry, SentryReportingErrorHandler } = await loadSentry(); + const { reloadApplicationOnChunkLoadError } = await import( + '../../app/common/application' + ); + setAuthorityDsn(undefined); + await initSentry(injector, 'user-1'); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const handler = new SentryReportingErrorHandler(); + const handled = new TypeError('Importing a module script failed.'); + reloadApplicationOnChunkLoadError(handled, vi.fn()); + handler.handleError(handled); + expect(handle_error).not.toHaveBeenCalled(); + const unhandled = new TypeError('Importing a module script failed.'); + handler.handleError(unhandled); + expect(handle_error).toHaveBeenCalledWith(unhandled); + vi.mocked(console.error).mockRestore(); + }); + it('does not load Sentry when the authority turns it off', async () => { const { initSentry, reportError } = await loadSentry(); setAuthorityDsn('');