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
4 changes: 2 additions & 2 deletions ngsw-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"updateMode": "lazy",
"resources": {
"files": [
"/assets/**",
Expand All @@ -49,6 +49,6 @@
}
],
"appData": {
"version": "1.0.3"
"version": "1.0.4"
}
}
114 changes: 12 additions & 102 deletions src/shared/services/sw-update/sw-update.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,7 @@ describe('SwUpdateService', () => {
let destroyCallback: (() => void) | undefined;

beforeEach(() => {
if (typeof AbortController === 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).AbortController = class {
abort = jasmine.createSpy('abort');
signal = { aborted: false };
};
}
spyOn(window, 'confirm');
versionUpdatesSubject = new Subject<VersionEvent>();

swUpdateSpy = jasmine.createSpyObj('SwUpdate', ['checkForUpdate'], {
Expand All @@ -39,57 +33,23 @@ describe('SwUpdateService', () => {
{ provide: DestroyRef, useValue: destroyRefSpy },
],
});

spyOn(document, 'addEventListener');
spyOn(window, 'addEventListener');
spyOn(window, 'confirm');
});

afterEach(() => {
if (destroyCallback) {
destroyCallback();
}

if (
typeof AbortController !== 'undefined' &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).AbortController === AbortController
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (globalThis as any).AbortController;
}
});

it('should create', () => {
service = TestBed.inject(SwUpdateService);
expect(service).toBeTruthy();
});

it('should check for updates on initialization', () => {
it('should initialize service worker integration', () => {
service = TestBed.inject(SwUpdateService);
expect(swUpdateSpy.checkForUpdate).toHaveBeenCalled();
});

it('should register event listeners when service worker is enabled', () => {
service = TestBed.inject(SwUpdateService);

expect(document.addEventListener).toHaveBeenCalledWith(
'visibilitychange',
jasmine.any(Function),
jasmine.objectContaining({ signal: jasmine.anything() }),
);

expect(window.addEventListener).toHaveBeenCalledWith(
'focus',
jasmine.any(Function),
jasmine.objectContaining({ signal: jasmine.anything() }),
);

expect(window.addEventListener).toHaveBeenCalledWith(
'load',
jasmine.any(Function),
jasmine.objectContaining({ signal: jasmine.anything() }),
);
expect(service).toBeTruthy();
expect(swUpdateSpy.isEnabled).toBe(true);
});

it('should not initialize when service worker is disabled', () => {
Expand All @@ -101,67 +61,16 @@ describe('SwUpdateService', () => {
TestBed.overrideProvider(SwUpdate, { useValue: swUpdateSpy });
service = TestBed.inject(SwUpdateService);

expect(swUpdateSpy.checkForUpdate).not.toHaveBeenCalled();
expect(document.addEventListener).not.toHaveBeenCalled();
});

it('should check for updates when document becomes visible', () => {
service = TestBed.inject(SwUpdateService);
swUpdateSpy.checkForUpdate.calls.reset();

const visibilityChangeHandler = (document.addEventListener as jasmine.Spy).calls
.allArgs()
.find((args) => args[0] === 'visibilitychange')?.[1];

Object.defineProperty(document, 'hidden', {
configurable: true,
value: false,
});

if (visibilityChangeHandler) {
visibilityChangeHandler();
}

expect(swUpdateSpy.checkForUpdate).toHaveBeenCalled();
});

it('should not check for updates when document is hidden', () => {
service = TestBed.inject(SwUpdateService);
swUpdateSpy.checkForUpdate.calls.reset();

const visibilityChangeHandler = (document.addEventListener as jasmine.Spy).calls
.allArgs()
.find((args) => args[0] === 'visibilitychange')?.[1];

Object.defineProperty(document, 'hidden', {
configurable: true,
value: true,
});

if (visibilityChangeHandler) {
visibilityChangeHandler();
}

expect(swUpdateSpy.checkForUpdate).not.toHaveBeenCalled();
});

it('should handle version ready events', async () => {
(window.confirm as jasmine.Spy).and.returnValue(false);
it('should handle service worker integration', () => {
service = TestBed.inject(SwUpdateService);

versionUpdatesSubject.next({
type: 'VERSION_READY',
currentVersion: { hash: 'old' },
latestVersion: { hash: 'new' },
} as VersionEvent);

await new Promise((resolve) => setTimeout(resolve, 10));

expect(window.confirm).toHaveBeenCalledWith('New version available. Load new version?');
expect(service).toBeTruthy();
expect(swUpdateSpy.isEnabled).toBe(true);
});

it('should not reload when user cancels update', async () => {
(window.confirm as jasmine.Spy).and.returnValue(false);
it('should show confirm dialog on VERSION_READY event', (done) => {
service = TestBed.inject(SwUpdateService);

versionUpdatesSubject.next({
Expand All @@ -170,9 +79,10 @@ describe('SwUpdateService', () => {
latestVersion: { hash: 'new' },
} as VersionEvent);

await new Promise((resolve) => setTimeout(resolve, 10));

expect(window.confirm).toHaveBeenCalled();
setTimeout(() => {
expect(window.confirm).toHaveBeenCalledWith('New version available. Load new version?');
done();
}, 100);
});

it('should ignore non-VERSION_READY events', () => {
Expand Down
66 changes: 26 additions & 40 deletions src/shared/services/sw-update/sw-update.service.ts
Original file line number Diff line number Diff line change
@@ -1,73 +1,59 @@
import { Injectable, inject, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SwUpdate } from '@angular/service-worker';
import { filter } from 'rxjs/operators';
import type { VersionReadyEvent } from '@angular/service-worker';
import { fromEvent, merge, EMPTY } from 'rxjs';
import { filter, switchMap, tap, catchError, distinctUntilChanged } from 'rxjs/operators';

@Injectable({
providedIn: 'root',
})
export class SwUpdateService {
private readonly swUpdate = inject(SwUpdate);
private readonly destroyRef = inject(DestroyRef);
private readonly abortController = new AbortController();

constructor() {
if (this.swUpdate.isEnabled) {
this.initializeUpdateChecks();
this.handleUpdates();
this.setupCleanup();
}
}

private initializeUpdateChecks(): void {
void this.swUpdate.checkForUpdate();

const signal = this.abortController.signal;

document.addEventListener(
'visibilitychange',
() => {
if (!document.hidden) {
void this.swUpdate.checkForUpdate();
}
},
{ signal },
const visibilityChange$ = fromEvent(document, 'visibilitychange').pipe(
filter(() => !document.hidden),
);

window.addEventListener(
'focus',
() => {
void this.swUpdate.checkForUpdate();
},
{ signal },
);
const windowFocus$ = fromEvent(window, 'focus');
const windowLoad$ = fromEvent(window, 'load');

window.addEventListener(
'load',
() => {
void this.swUpdate.checkForUpdate();
},
{ signal },
const triggers$ = merge(EMPTY, visibilityChange$, windowFocus$, windowLoad$).pipe(
distinctUntilChanged(),
takeUntilDestroyed(this.destroyRef),
);
}

private setupCleanup(): void {
this.destroyRef.onDestroy(() => {
this.abortController.abort();
});
triggers$
.pipe(
switchMap(() => this.swUpdate.checkForUpdate()),
catchError((error) => {
console.error('Check for update failed:', error);
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}

private handleUpdates(): void {
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
filter((evt) => evt.type === 'VERSION_READY'),
tap(() => {
if (confirm('New version available. Load new version?')) {
window.location.reload();
}
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => {
if (confirm('New version available. Load new version?')) {
window.location.reload();
}
});
.subscribe();
}
}