From dd8ab504dc079a6a8c8aaba2c84b2cc42016d7ee Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Thu, 8 Oct 2026 18:25:03 +1100 Subject: [PATCH] perf: cut initial bundle size by 44% The main bundle loaded Material overlay code, the en-AU locale, cloud uploads and date-fns at start-up, though start-up does not use them. - Load the snackbar lazily. Notifications made before it loads open when it does. - Defer the upload list, update toast and global banner. Use standalone Material directives, as @defer cannot defer NgModules. - Load the en-AU fallback locale as a lazy chunk. Start-up waits for it. - Import the tenant expiry helper only for sys-admins. Format the build-time debug log with Intl. - Remove the dead Sentry call and the unused setToken method. main: 824.6 kB -> 461.6 kB raw, 194.9 kB -> 122.4 kB transfer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/app/app.ts | 101 +++++++++++++------------ src/app/common/locale.service.ts | 14 +++- src/app/common/notifications.ts | 35 ++++++++- src/app/common/settings.service.ts | 11 ++- src/app/ui/global-banner.component.ts | 4 +- src/app/ui/global-loading.component.ts | 4 +- src/app/users/users.service.ts | 28 ------- src/tests/common/notifications.spec.ts | 17 +++++ 8 files changed, 123 insertions(+), 91 deletions(-) diff --git a/src/app/app.ts b/src/app/app.ts index 66b18dde9..9e9d3ff05 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -3,17 +3,12 @@ import { computed, DestroyRef, inject, + Injector, OnInit, signal, } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { SwUpdate } from '@angular/service-worker'; -import { - get, - invalidateToken, - isMock, - onlineState, -} from '@placeos/ts-client'; +import { get, invalidateToken, isMock, onlineState } from '@placeos/ts-client'; import { ActivatedRoute, @@ -28,14 +23,12 @@ import { setNotifyOutlet } from './common/notifications'; import { PlaceSettings, setLoadingMessage, setupPlace } from './common/placeos'; import { SettingsService } from './common/settings.service'; import { signalFromClient, waitForSignalValue } from './common/signals'; -import { syncUploadToken } from './common/uploads'; import { currentUser } from './common/user-state'; import { BackofficeUsersService } from './users/users.service'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { PlaceTenant } from './admin/staff-api.component'; -import { tenantExpiryBanner } from './admin/staff-api.utilities'; +import { MatProgressBar } from '@angular/material/progress-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import type { PlaceTenant } from './admin/staff-api.component'; import { localeFromUrl, LocaleService, @@ -67,12 +60,16 @@ function browserOnline() { template: `
@if (!loading()) { - + @defer (on idle) { + + }
@if (!simple()) { - + @defer (on idle) { + + } } } @else {
} @if (update_available() && !loading()) { -
-
-
-
Update available
-

- Refresh to load the latest Backoffice version. -

+ + @defer { +
+
+
+
+ Update available +
+

+ Refresh to load the latest Backoffice version. +

+
+
- -
-
+ + } } `, styles: [ @@ -140,17 +142,17 @@ function browserOnline() { GlobalBannerComponent, RouterOutlet, UploadListComponent, - MatProgressBarModule, + MatProgressBar, GlobalLoadingComponent, IconComponent, - MatTooltipModule, + MatTooltip, ], }) export class AppComponent extends AsyncHandler implements OnInit { private _settings = inject(SettingsService); private _users = inject(BackofficeUsersService); private _cache = inject(SwUpdate); - private _snackbar = inject(MatSnackBar); + private _injector = inject(Injector); private _router = inject(Router); private _route = inject(ActivatedRoute); private _locale = inject(LocaleService, { optional: true }); @@ -186,7 +188,12 @@ export class AppComponent extends AsyncHandler implements OnInit { localStorage.setItem('BACKOFFICE.locale', locale); } }); - setNotifyOutlet(this._snackbar); + // Load the snackbar lazily to keep the overlay code out of the initial bundle + setNotifyOutlet( + import('@angular/material/snack-bar').then(({ MatSnackBar }) => + this._injector.get(MatSnackBar), + ), + ); setTranslationService(this._locale); this.loading.set(true); setLoadingMessage('Loading application settings...'); @@ -215,8 +222,6 @@ export class AppComponent extends AsyncHandler implements OnInit { // TranslatePipe is pure, so load translations before the shell renders await this._initLocale(); this.loading.set(false); - setLoadingMessage('Initialising upload service...'); - this.timeout('init_uploads', () => syncUploadToken()); this._router.events.subscribe((event) => { if (event instanceof NavigationEnd) { this.simple.set(this._router.url.includes('mqtt')); @@ -240,7 +245,10 @@ export class AppComponent extends AsyncHandler implements OnInit { /** Show one banner for staff tenants with expiring secrets */ private async _checkTenants() { if (!currentUser()?.sys_admin) return; - const tenants = await get('/api/staff/v1/tenants').catch(() => []); + const [tenants, { tenantExpiryBanner }] = await Promise.all([ + get('/api/staff/v1/tenants').catch(() => []), + import('./admin/staff-api.utilities'), + ]); const banner = tenantExpiryBanner( Array.isArray(tenants) ? (tenants as PlaceTenant[]) : [], ); @@ -249,7 +257,7 @@ export class AppComponent extends AsyncHandler implements OnInit { /** * Set the locale from the URL `lang` param, storage or the browser languages. - * Resolves when translations load, or after a timeout. + * Resolves when the fallback and locale translations load, or after a timeout. */ private async _initLocale() { let load: Promise | undefined; @@ -284,10 +292,9 @@ export class AppComponent extends AsyncHandler implements OnInit { } catch { // Ignore locale parsing errors } - if (!load) return; // Do not block start up on a slow or failed locale file await Promise.race([ - load.catch(() => undefined), + Promise.all([this._locale?.ready, load]).catch(() => undefined), new Promise((resolve) => setTimeout(resolve, LOCALE_TIMEOUT_MS)), ]); } diff --git a/src/app/common/locale.service.ts b/src/app/common/locale.service.ts index ea19bfb74..25a067fe6 100644 --- a/src/app/common/locale.service.ts +++ b/src/app/common/locale.service.ts @@ -1,7 +1,5 @@ import { Service } from '@angular/core'; -import * as DEFAULT_LOCALE from '../../../public/assets/locale/en-AU.json'; - import { showMetadata } from '@placeos/ts-client'; import { log } from './general'; @@ -93,8 +91,16 @@ export class LocaleService { private _cache_time = 7 * 24 * 60 * 60 * 1000; private _load_promises: Record> = {}; - private _default_mappings: Record = - removeNesting(DEFAULT_LOCALE); + private _default_mappings: Record = {}; + /** + * Resolves when the fallback translations have loaded. + * They load lazily to keep them out of the initial bundle. + */ + public readonly ready: Promise = import( + '../../../public/assets/locale/en-AU.json' + ).then(({ default: data }) => { + this._default_mappings = removeNesting(data); + }); private _locale_mappings: Record> = {}; public locale_folder = 'assets/locale'; diff --git a/src/app/common/notifications.ts b/src/app/common/notifications.ts index bdcc10508..b6e6709c4 100644 --- a/src/app/common/notifications.ts +++ b/src/app/common/notifications.ts @@ -1,11 +1,22 @@ -import { MatSnackBar } from '@angular/material/snack-bar'; +import type { MatSnackBar } from '@angular/material/snack-bar'; import { ApplicationIcon } from './types'; -let _service: MatSnackBar; +/** Snackbar service, or a promise of it while the snackbar module loads */ +let _service: MatSnackBar | Promise | null = null; -export function setNotifyOutlet(snackbar: MatSnackBar) { +/** + * Set the snackbar used for notifications. + * Pass a promise to load the snackbar module lazily. + * Notifications made before it resolves open once it does. + */ +export function setNotifyOutlet(snackbar: MatSnackBar | Promise) { _service = snackbar; + if (snackbar instanceof Promise) { + snackbar.then((service) => { + if (_service === snackbar) _service = service; + }); + } } /** @@ -29,7 +40,23 @@ export function notify( if (!_service) { throw new Error("Snackbar service hasn't been initialised"); } - const snackbar_ref = _service.open(message, action, { + if (_service instanceof Promise) { + _service.then((service) => + openNotification(service, type, message, action, on_action), + ); + return; + } + openNotification(_service, type, message, action, on_action); +} + +function openNotification( + service: MatSnackBar, + type: string, + message: string, + action: string, + on_action?: () => void, +) { + const snackbar_ref = service.open(message, action, { panelClass: [type], duration: 5000, }); diff --git a/src/app/common/settings.service.ts b/src/app/common/settings.service.ts index c920e5337..d5f93f573 100644 --- a/src/app/common/settings.service.ts +++ b/src/app/common/settings.service.ts @@ -8,7 +8,6 @@ import { } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { showMetadata, updateMetadata } from '@placeos/ts-client'; -import { format, isSameDay } from 'date-fns'; import { AsyncHandler } from './async-handler.class'; import { getItemWithKeys, log } from './general'; @@ -74,9 +73,13 @@ export class SettingsService extends AsyncHandler { super(); const now = new Date(); const time = new Date(VERSION.time); - const built = isSameDay(now, time) - ? `Today at ${format(time, 'h:mma')}` - : format(time, 'do MMM yyyy, h:mma'); + const built = + now.toDateString() === time.toDateString() + ? `Today at ${time.toLocaleTimeString([], { timeStyle: 'short' })}` + : time.toLocaleString([], { + dateStyle: 'medium', + timeStyle: 'short', + }); log('CORE', `${VERSION.semver}`, null, 'debug', true); log('APP', `${VERSION.hash} | Built: ${built}`, null, 'debug', true); this.init(); diff --git a/src/app/ui/global-banner.component.ts b/src/app/ui/global-banner.component.ts index 9e2464aaa..334e6dc91 100644 --- a/src/app/ui/global-banner.component.ts +++ b/src/app/ui/global-banner.component.ts @@ -1,5 +1,5 @@ import { Component, inject } from '@angular/core'; -import { MatRippleModule } from '@angular/material/core'; +import { MatRipple } from '@angular/material/core'; import { SettingsService } from '../common/settings.service'; import { IconComponent } from './icon.component'; @@ -39,7 +39,7 @@ export interface BannerDetails { } `, ], - imports: [IconComponent, MatRippleModule], + imports: [IconComponent, MatRipple], }) export class GlobalBannerComponent { private _settings = inject(SettingsService); diff --git a/src/app/ui/global-loading.component.ts b/src/app/ui/global-loading.component.ts index 0fa2b5582..907ab6e0a 100644 --- a/src/app/ui/global-loading.component.ts +++ b/src/app/ui/global-loading.component.ts @@ -1,7 +1,7 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { authority, isOnline, token } from '@placeos/ts-client'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatProgressBar } from '@angular/material/progress-bar'; import { AsyncHandler } from '../common/async-handler.class'; import { getLoadingMessage } from '../common/placeos'; import { SettingsService } from '../common/settings.service'; @@ -46,7 +46,7 @@ import { waitForSignalValue } from '../common/signals'; } `, ], - imports: [MatProgressBarModule], + imports: [MatProgressBar], }) export class GlobalLoadingComponent extends AsyncHandler implements OnInit { private _settings = inject(SettingsService); diff --git a/src/app/users/users.service.ts b/src/app/users/users.service.ts index 0d41ba58b..f5989bfbe 100644 --- a/src/app/users/users.service.ts +++ b/src/app/users/users.service.ts @@ -7,7 +7,6 @@ import { onlineState, queryUsers, } from '@placeos/ts-client'; -import { Md5 } from 'ts-md5'; import { AsyncHandler } from '../common/async-handler.class'; import { SettingsService } from '../common/settings.service'; @@ -15,9 +14,6 @@ import { loadSupportAccess } from '../common/support-access'; import { FilterFn } from '../common/types'; import { current_user, setCurrentUser } from '../common/user-state'; -import * as Sentry from '@sentry/browser'; -import { addDays } from 'date-fns'; - type ServiceItem = PlaceUser; @Service() @@ -102,9 +98,6 @@ export class BackofficeUsersService extends AsyncHandler { } await loadSupportAccess(user); setCurrentUser(user); - Sentry.withScope((scope) => - scope.setUser({ email: user.email }), - ); this.state.set('success'); this._initialised.set(true); // Trigger dark mode getter to apply current theme @@ -121,27 +114,6 @@ export class BackofficeUsersService extends AsyncHandler { }); } - /** - * Manually set the user access token - * @param token Token to set - * @param expiry Expiry time of the token - */ - public setToken(token: string, expiry: number) { - if (!expiry) { - expiry = addDays(Date.now(), 7).valueOf(); - } - const path = `${location.origin}${ - this._settings.get('composer.route') || '' - }/oauth-resp.html`; - if (localStorage) { - const client_id = Md5.hashStr(path); - localStorage.setItem(`${client_id}_access_token`, token); - localStorage.setItem(`${client_id}_expires_at`, `${expiry}`); - location.reload(); - } - return path; - } - /** * Login with given credentials * @param fields Key value pairs of post parameters diff --git a/src/tests/common/notifications.spec.ts b/src/tests/common/notifications.spec.ts index c1d692dff..53ec32850 100644 --- a/src/tests/common/notifications.spec.ts +++ b/src/tests/common/notifications.spec.ts @@ -65,6 +65,23 @@ describe('notifications.ts', () => { // If set correctly, notify should not throw expect(() => notify('test', 'message')).not.toThrow(); }); + + it('should open notifications made while a lazy snackbar loads', async () => { + let resolve: (snackbar: unknown) => void; + const pending = new Promise((r) => (resolve = r)); + setNotifyOutlet(pending as any); + + notify('info', 'Early message'); + expect(mock_snackbar.open).not.toHaveBeenCalled(); + + resolve(mock_snackbar); + await pending; + expect(mock_snackbar.open).toHaveBeenCalledWith( + 'Early message', + 'OK', + expect.any(Object), + ); + }); }); describe('notify', () => {