Skip to content

Commit 81861bf

Browse files
feat: refactor to remove zonejs from UserActivityService
1 parent cd88527 commit 81861bf

File tree

2 files changed

+57
-46
lines changed

2 files changed

+57
-46
lines changed

projects/keycloak-angular/src/lib/services/user-activity.service.spec.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,19 @@
66
* found in the LICENSE file at https://github.com/mauriciovigolo/keycloak-angular/blob/main/LICENSE.md
77
*/
88

9-
import { TestBed } from '@angular/core/testing';
10-
import { NgZone } from '@angular/core';
9+
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
1110
import { PLATFORM_ID } from '@angular/core';
1211

1312
import { UserActivityService } from './user-activity.service';
1413

1514
describe('UserActivityService', () => {
1615
let service: UserActivityService;
17-
let ngZone: NgZone;
1816

1917
beforeEach(() => {
2018
TestBed.configureTestingModule({
2119
providers: [UserActivityService, { provide: PLATFORM_ID, useValue: 'browser' }]
2220
});
2321
service = TestBed.inject(UserActivityService);
24-
ngZone = TestBed.inject(NgZone);
2522
});
2623

2724
afterEach(() => {
@@ -56,22 +53,25 @@ describe('UserActivityService', () => {
5653
const expectedEvents = ['mousemove', 'touchstart', 'keydown', 'click', 'scroll'];
5754

5855
expectedEvents.forEach((event) => {
59-
expect(addEventListenerSpy).toHaveBeenCalledWith(event, jasmine.any(Function), undefined);
56+
expect(addEventListenerSpy).toHaveBeenCalledWith(
57+
event,
58+
jasmine.any(Function),
59+
jasmine.objectContaining({ passive: true })
60+
);
6061
});
6162
});
6263
});
6364
});
6465

65-
describe('updateLastActivity', () => {
66-
it('should update the last activity timestamp to the current time', () => {
66+
describe('debouncedUpdate', () => {
67+
it('should update the last activity timestamp to the current time after debounce', fakeAsync(() => {
6768
const currentTime = Date.now();
6869

69-
spyOn(ngZone, 'run').and.callFake((fn: Function) => fn());
70-
71-
service['updateLastActivity']();
70+
service['debouncedUpdate']();
71+
tick(300);
7272

7373
expect(service.lastActivitySignal()).toBeGreaterThanOrEqual(currentTime);
74-
});
74+
}));
7575
});
7676

7777
describe('lastActivityTime', () => {
@@ -103,13 +103,26 @@ describe('UserActivityService', () => {
103103

104104
describe('ngOnDestroy', () => {
105105
it('should clean up resources when destroyed', () => {
106-
const destroySpy = spyOn(service['destroy$'], 'next').and.callThrough();
107-
const completeSpy = spyOn(service['destroy$'], 'complete').and.callThrough();
106+
const removeEventListenerSpy = spyOn(window, 'removeEventListener');
107+
service.startMonitoring();
108108

109109
service.ngOnDestroy();
110110

111-
expect(destroySpy).toHaveBeenCalled();
112-
expect(completeSpy).toHaveBeenCalled();
111+
const expectedEvents = ['mousemove', 'touchstart', 'keydown', 'click', 'scroll'];
112+
expectedEvents.forEach((event) => {
113+
expect(removeEventListenerSpy).toHaveBeenCalledWith(event, jasmine.any(Function));
114+
});
113115
});
116+
117+
it('should clear debounce timeout if active', fakeAsync(() => {
118+
service['debouncedUpdate']();
119+
service.ngOnDestroy();
120+
121+
tick(300);
122+
123+
const initialValue = service.lastActivitySignal();
124+
tick(300);
125+
expect(service.lastActivitySignal()).toBe(initialValue);
126+
}));
114127
});
115128
});

projects/keycloak-angular/src/lib/services/user-activity.service.ts

Lines changed: 29 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@
66
* found in the LICENSE file at https://github.com/mauriciovigolo/keycloak-angular/blob/main/LICENSE.md
77
*/
88

9-
import { Injectable, OnDestroy, NgZone, signal, computed, inject, PLATFORM_ID } from '@angular/core';
9+
import { Injectable, OnDestroy, signal, computed, inject, PLATFORM_ID } from '@angular/core';
1010
import { isPlatformBrowser } from '@angular/common';
11-
import { fromEvent, Subject } from 'rxjs';
12-
import { debounceTime, takeUntil } from 'rxjs/operators';
1311

1412
/**
1513
* Service to monitor user activity in an Angular application.
@@ -22,19 +20,15 @@ import { debounceTime, takeUntil } from 'rxjs/operators';
2220
*/
2321
@Injectable()
2422
export class UserActivityService implements OnDestroy {
25-
private ngZone = inject(NgZone);
26-
2723
/**
2824
* Signal to store the timestamp of the last user activity.
2925
* The timestamp is represented as the number of milliseconds since epoch.
3026
*/
3127
private lastActivity = signal<number>(Date.now());
32-
33-
/**
34-
* Subject to signal the destruction of the service.
35-
* Used to clean up RxJS subscriptions.
36-
*/
37-
private destroy$ = new Subject<void>();
28+
private isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
29+
private eventListeners: Array<() => void> = [];
30+
private debounceTimeoutId: any = null;
31+
private readonly debounceTime = 300;
3832

3933
/**
4034
* Computed signal to expose the last user activity as a read-only signal.
@@ -43,34 +37,34 @@ export class UserActivityService implements OnDestroy {
4337

4438
/**
4539
* Starts monitoring user activity events (`mousemove`, `touchstart`, `keydown`, `click`, `scroll`)
46-
* and updates the last activity timestamp using RxJS with debounce.
47-
* The events are processed outside Angular zone for performance optimization.
40+
* and updates the last activity timestamp using debouncing for performance optimization.
4841
*/
4942
startMonitoring(): void {
50-
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
51-
if (!isBrowser) {
43+
if (!this.isBrowser) {
5244
return;
5345
}
5446

55-
this.ngZone.runOutsideAngular(() => {
56-
const events = ['mousemove', 'touchstart', 'keydown', 'click', 'scroll'];
47+
const events: Array<keyof WindowEventMap> = ['mousemove', 'touchstart', 'keydown', 'click', 'scroll'];
5748

58-
events.forEach((event) => {
59-
fromEvent(window, event)
60-
.pipe(debounceTime(300), takeUntil(this.destroy$))
61-
.subscribe(() => this.updateLastActivity());
62-
});
49+
const handler = () => this.debouncedUpdate();
50+
51+
events.forEach((event) => {
52+
window.addEventListener(event, handler, { passive: true });
53+
this.eventListeners.push(() => window.removeEventListener(event, handler));
6354
});
6455
}
6556

6657
/**
67-
* Updates the last activity timestamp to the current time.
68-
* This method runs inside Angular's zone to ensure reactivity with Angular signals.
58+
* Updates the last activity timestamp with debounce.
6959
*/
70-
private updateLastActivity(): void {
71-
this.ngZone.run(() => {
60+
private debouncedUpdate(): void {
61+
if (this.debounceTimeoutId !== null) {
62+
clearTimeout(this.debounceTimeoutId);
63+
}
64+
this.debounceTimeoutId = setTimeout(() => {
7265
this.lastActivity.set(Date.now());
73-
});
66+
this.debounceTimeoutId = null;
67+
}, this.debounceTime);
7468
}
7569

7670
/**
@@ -82,7 +76,7 @@ export class UserActivityService implements OnDestroy {
8276
}
8377

8478
/**
85-
* Determines whether the user interacted with the application, meaning it is activily using the application, based on
79+
* Determines whether the user interacted with the application, meaning it is actively using the application, based on
8680
* the specified duration.
8781
* @param timeout - The inactivity timeout in milliseconds.
8882
* @returns {boolean} `true` if the user is inactive, otherwise `false`.
@@ -92,11 +86,15 @@ export class UserActivityService implements OnDestroy {
9286
}
9387

9488
/**
95-
* Cleans up RxJS subscriptions and resources when the service is destroyed.
89+
* Cleans up event listeners and debouncing timer on destroy.
9690
* This method is automatically called by Angular when the service is removed.
9791
*/
9892
ngOnDestroy(): void {
99-
this.destroy$.next();
100-
this.destroy$.complete();
93+
this.eventListeners.forEach((remove) => remove());
94+
this.eventListeners = [];
95+
if (this.debounceTimeoutId !== null) {
96+
clearTimeout(this.debounceTimeoutId);
97+
this.debounceTimeoutId = null;
98+
}
10199
}
102100
}

0 commit comments

Comments
 (0)