Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/app/common/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { log } from './general';
let _timer: ReturnType<typeof setInterval>;
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<object>();

export const updateAvailable = signal(false);

Expand Down Expand Up @@ -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) {
Expand All @@ -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.
*
Expand Down
26 changes: 21 additions & 5 deletions src/app/common/notifications.ts
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -9,13 +10,25 @@ let _service: MatSnackBar | Promise<MatSnackBar> | 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<MatSnackBar>) {
_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,
),
);
}
}

Expand All @@ -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;
}
Expand Down
8 changes: 6 additions & 2 deletions src/app/common/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` */
Expand Down Expand Up @@ -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);
}
Expand Down
12 changes: 12 additions & 0 deletions src/tests/common/notifications.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/tests/common/sentry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down
Loading