fix(pwa): avoid startup stalls during network changes

This commit is contained in:
johannesjo 2026-05-14 12:40:51 +02:00
parent 362847036d
commit 7f96181738
5 changed files with 240 additions and 17 deletions

View file

@ -9,14 +9,6 @@
"files": ["/favicon.ico", "/index.html", "/manifest.json", "/*.css", "/*.js"]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
},
{
"name": "criticalAssets",
"installMode": "prefetch",
@ -27,11 +19,19 @@
"/assets/icons/icon-*.png",
"/assets/icons/favicon-*.png",
"/assets/i18n/en.json",
"/assets/fonts/MaterialIcons-Regular.ttf",
"/assets/fonts/material-symbols-outlined.woff2",
"/*.(otf|ttf|woff|woff2)"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
},
{
"name": "externalAssets",
"installMode": "lazy",

View file

@ -0,0 +1,128 @@
import { fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
import { InitialPwaUpdateCheckService } from './initial-pwa-update-check.service';
import { SwUpdate } from '@angular/service-worker';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';
import { Log } from './log';
const INITIAL_PWA_UPDATE_CHECK_TIMEOUT_MS = 8000;
describe('InitialPwaUpdateCheckService', () => {
let originalOnLineDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
originalOnLineDescriptor = Object.getOwnPropertyDescriptor(navigator, 'onLine');
Object.defineProperty(navigator, 'onLine', {
value: true,
configurable: true,
});
});
afterEach(() => {
restoreNavigatorOnLine(originalOnLineDescriptor);
TestBed.resetTestingModule();
});
it('should defer the update check until subscribed', () => {
const { service, swUpdate } = setup(Promise.resolve(false));
expect(swUpdate.checkForUpdate).not.toHaveBeenCalled();
const sub = service.afterInitialUpdateCheck$.subscribe();
expect(swUpdate.checkForUpdate).toHaveBeenCalledTimes(1);
sub.unsubscribe();
});
it('should emit when no update is available', async () => {
const { service } = setup(Promise.resolve(false));
const result = await firstValueFrom(service.afterInitialUpdateCheck$);
expect(result).toBeUndefined();
});
it('should share the initial update check result', async () => {
const { service, swUpdate } = setup(Promise.resolve(false));
await firstValueFrom(service.afterInitialUpdateCheck$);
await firstValueFrom(service.afterInitialUpdateCheck$);
expect(swUpdate.checkForUpdate).toHaveBeenCalledTimes(1);
});
it('should emit when the update check rejects', async () => {
spyOn(Log, 'warn');
const { service } = setup(Promise.reject(new Error('network failed')));
const result = await firstValueFrom(service.afterInitialUpdateCheck$);
expect(result).toBeUndefined();
expect(Log.warn).toHaveBeenCalled();
});
it('should emit when the update check stalls', fakeAsync(() => {
spyOn(Log, 'warn');
const { service } = setup(new Promise<boolean>(() => undefined));
let didEmit = false;
service.afterInitialUpdateCheck$.subscribe(() => {
didEmit = true;
});
tick(INITIAL_PWA_UPDATE_CHECK_TIMEOUT_MS);
flushMicrotasks();
expect(didEmit).toBeTrue();
expect(Log.warn).toHaveBeenCalled();
}));
it('should skip the update check when service worker updates are disabled', async () => {
const { service, swUpdate } = setup(Promise.resolve(false), false);
const result = await firstValueFrom(service.afterInitialUpdateCheck$);
expect(result).toBeUndefined();
expect(swUpdate.checkForUpdate).not.toHaveBeenCalled();
});
});
const setup = (
checkForUpdateResult: Promise<boolean>,
isEnabled: boolean = true,
): {
service: InitialPwaUpdateCheckService;
swUpdate: jasmine.SpyObj<SwUpdate>;
} => {
const swUpdate = jasmine.createSpyObj<SwUpdate>('SwUpdate', ['checkForUpdate']);
Object.defineProperty(swUpdate, 'isEnabled', {
value: isEnabled,
configurable: true,
});
swUpdate.checkForUpdate.and.returnValue(checkForUpdateResult);
const translateService = jasmine.createSpyObj<TranslateService>('TranslateService', [
'instant',
]);
translateService.instant.and.returnValue('Update?');
TestBed.configureTestingModule({
providers: [
InitialPwaUpdateCheckService,
{ provide: SwUpdate, useValue: swUpdate },
{ provide: TranslateService, useValue: translateService },
],
});
return {
service: TestBed.inject(InitialPwaUpdateCheckService),
swUpdate,
};
};
const restoreNavigatorOnLine = (descriptor: PropertyDescriptor | undefined): void => {
if (descriptor) {
Object.defineProperty(navigator, 'onLine', descriptor);
} else {
delete (navigator as { onLine?: boolean }).onLine;
}
};

View file

@ -1,7 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { IS_ELECTRON } from '../app.constants';
import { EMPTY, from, Observable, of } from 'rxjs';
import { concatMap } from 'rxjs/operators';
import { defer, EMPTY, from, Observable, of } from 'rxjs';
import { catchError, concatMap, shareReplay, timeout } from 'rxjs/operators';
import { T } from '../t.const';
import { SwUpdate } from '@angular/service-worker';
import { isOnline } from '../util/is-online';
@ -9,6 +9,8 @@ import { TranslateService } from '@ngx-translate/core';
import { Log } from './log';
import { confirmDialog } from '../util/native-dialogs';
const INITIAL_PWA_UPDATE_CHECK_TIMEOUT_MS = 8000;
@Injectable({
providedIn: 'root',
})
@ -19,7 +21,12 @@ export class InitialPwaUpdateCheckService {
// NOTE: check currently triggered by sync effect
afterInitialUpdateCheck$: Observable<void> =
!IS_ELECTRON && this._swUpdate.isEnabled && isOnline()
? from(this._swUpdate.checkForUpdate()).pipe(
? defer(() => from(this._swUpdate.checkForUpdate())).pipe(
timeout(INITIAL_PWA_UPDATE_CHECK_TIMEOUT_MS),
catchError((err: unknown) => {
Log.warn('InitialPwaUpdateCheckService: update check failed', err);
return of(false);
}),
concatMap((isUpdateAvailable) => {
Log.log(
'___________isServiceWorkerUpdateAvailable____________',
@ -34,6 +41,7 @@ export class InitialPwaUpdateCheckService {
}
return of(undefined);
}),
shareReplay(1),
)
: of(undefined);
}

View file

@ -1,14 +1,26 @@
import { TestBed } from '@angular/core/testing';
import { fakeAsync, flushMicrotasks, TestBed, tick } from '@angular/core/testing';
import { MaterialIconsLoaderService } from './material-icons-loader.service';
import { BodyClass } from '../app.constants';
const MATERIAL_ICONS_FONT = '24px "Material Symbols Outlined"';
const MATERIAL_ICONS_FONT_READY_TIMEOUT_MS = 3000;
describe('MaterialIconsLoaderService', () => {
let service: MaterialIconsLoaderService;
let originalFonts: FontFaceSet | undefined;
beforeEach(() => {
originalFonts = document.fonts;
document.body.classList.remove(BodyClass.isMaterialSymbolsLoaded);
TestBed.configureTestingModule({});
service = TestBed.inject(MaterialIconsLoaderService);
});
afterEach(() => {
setDocumentFonts(originalFonts);
document.body.classList.remove(BodyClass.isMaterialSymbolsLoaded);
});
it('should load icons on first call', async () => {
const icons = await service.loadIcons();
expect(icons).toBeDefined();
@ -28,4 +40,65 @@ describe('MaterialIconsLoaderService', () => {
]);
expect(icons1).toBe(icons2);
});
it('should reveal icons when FontFaceSet is unavailable', async () => {
setDocumentFonts(undefined);
await service.ensureFontReady();
expect(
document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded),
).toBeTrue();
});
it('should wait for the material symbols font before revealing icons', async () => {
const load = jasmine.createSpy('load').and.resolveTo([]);
setDocumentFonts({ load } as unknown as FontFaceSet);
await service.ensureFontReady();
expect(load).toHaveBeenCalledWith(MATERIAL_ICONS_FONT);
expect(
document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded),
).toBeTrue();
});
it('should reveal icons if the font readiness probe stalls', fakeAsync(() => {
const load = jasmine
.createSpy('load')
.and.returnValue(new Promise<FontFace[]>(() => undefined));
setDocumentFonts({ load } as unknown as FontFaceSet);
let didResolve = false;
service.ensureFontReady().then(() => {
didResolve = true;
});
tick(MATERIAL_ICONS_FONT_READY_TIMEOUT_MS);
flushMicrotasks();
expect(didResolve).toBeTrue();
expect(
document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded),
).toBeTrue();
}));
it('should reveal icons if the font readiness probe rejects', async () => {
const load = jasmine
.createSpy('load')
.and.returnValue(Promise.reject(new Error('font failed')));
setDocumentFonts({ load } as unknown as FontFaceSet);
await service.ensureFontReady();
expect(
document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded),
).toBeTrue();
});
});
const setDocumentFonts = (fonts: FontFaceSet | undefined): void => {
Object.defineProperty(document, 'fonts', {
value: fonts,
configurable: true,
});
};

View file

@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
import { BodyClass } from '../app.constants';
const MATERIAL_ICONS_FONT = '24px "Material Symbols Outlined"';
const MATERIAL_ICONS_FONT_READY_TIMEOUT_MS = 3000;
@Injectable({
providedIn: 'root',
@ -41,7 +42,8 @@ export class MaterialIconsLoaderService {
return Promise.resolve();
}
if (!('fonts' in document)) {
const fonts = document.fonts;
if (!fonts?.load) {
body.classList.add(BodyClass.isMaterialSymbolsLoaded);
return Promise.resolve();
}
@ -50,7 +52,7 @@ export class MaterialIconsLoaderService {
return this.fontReadyPromise;
}
this.fontReadyPromise = this._loadFont(body);
this.fontReadyPromise = this._loadFont(body, fonts);
return this.fontReadyPromise;
}
@ -60,10 +62,22 @@ export class MaterialIconsLoaderService {
return MATERIAL_ICONS;
}
private async _loadFont(body: HTMLElement): Promise<void> {
private async _loadFont(body: HTMLElement, fonts: FontFaceSet): Promise<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
await document.fonts.load(MATERIAL_ICONS_FONT);
await Promise.race([
fonts.load(MATERIAL_ICONS_FONT),
new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, MATERIAL_ICONS_FONT_READY_TIMEOUT_MS);
}),
]);
} catch {
// Keep the UI usable if the browser fails the font readiness probe.
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
body.classList.add(BodyClass.isMaterialSymbolsLoaded);
}
}