Skip to content

Commit

Permalink
feat(interruptsource): support ssr with the default interrupt sources
Browse files Browse the repository at this point in the history
enable lazy initialization of the event target and skip the initialization when rendering on a
server platform to avoid unsafe references to window or document

fix grbsk#77, grbsk#115
  • Loading branch information
cshouts-tasc committed Sep 29, 2021
1 parent e2af040 commit 01cff2e
Show file tree
Hide file tree
Showing 18 changed files with 172 additions and 33 deletions.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ For example, consider an email application. For increased security, the applicat

`@ng-idle/core` can detect that the user is clicking, typing, touching, scrolling, etc. and know that the user is still active. It can work with `@ng-idle/keepalive` to ping the server every few minutes to keep them logged in. In this case, as long as the user is doing something, they stay logged in. If they step away from the computer, we can present a warning dialog, and then after a countdown, log them out.

## Server-Side Rendering/Universal

@ng-idle/core uses DOM events on various targets to detect user activity. However, when using SSR/Universal Rendering the app is not always running in the browser and thus may not have access to these DOM targets, causing your app to potentially crash or throw errors as it tries to use browser globals like `document` and `window` through @ng-idle.

`EventTargetInterruptSource` and all the interrupt sources that derive from it (such as `DocumentInterruptSource`, `WindowInterruptSource`, and `StorageInterruptSource`) are designed to lazily initialize the event target listeners for compatibility with server-side rendering. The `EventTargetInterruptSource` will detect whether your app is running in the browser or on the server by using [`isPlatformServer`](https://angular.io/api/common/isPlatformServer) and will skip initialization of the event target listeners when run on the server.

## Developing

This project was developed using the NodeJS version found in the `.nvmrc` file. You may experience problems using older versions. Try [NVM](https://github.com/creationix/nvm) or similar to manage different versions of Node concurrently. If using NVM, you can execute `nvm install` to download and switch to the correct version.
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,4 @@
"path": "./node_modules/cz-conventional-changelog"
}
}
}
}
4 changes: 2 additions & 2 deletions projects/core/src/lib/defaultinterruptsources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export function createDefaultInterruptSources(
'mousemove keydown DOMMouseScroll mousewheel mousedown touchstart touchmove scroll',
options
),
new StorageInterruptSource()
new StorageInterruptSource(options)
];
}

export const DEFAULT_INTERRUPTSOURCES: any[] = createDefaultInterruptSources();
export const DEFAULT_INTERRUPTSOURCES = createDefaultInterruptSources();
20 changes: 20 additions & 0 deletions projects/core/src/lib/documentinterruptsource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DocumentInterruptSource } from './documentinterruptsource';
describe('core/DocumentInterruptSource', () => {
it('emits onInterrupt event when attached and event is fired', fakeAsync(() => {
const source = new DocumentInterruptSource('click');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -16,8 +17,24 @@ describe('core/DocumentInterruptSource', () => {
source.detach();
}));

it('does not emit events when running on a server', fakeAsync(() => {
const source = new DocumentInterruptSource('click');
const options = { platformId: 'server' as unknown as object };
source.initialize(options);
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

const expected = new Event('click');
document.documentElement.dispatchEvent(expected);

expect(source.onInterrupt.emit).not.toHaveBeenCalled();

source.detach();
}));

it('does not emit onInterrupt event when detached and event is fired', fakeAsync(() => {
const source = new DocumentInterruptSource('click');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();

// make it interesting by attaching and detaching
Expand All @@ -32,6 +49,7 @@ describe('core/DocumentInterruptSource', () => {

it('should not emit onInterrupt event when Chrome desktop notifications are visible', fakeAsync(() => {
const source = new DocumentInterruptSource('mousemove');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -47,6 +65,7 @@ describe('core/DocumentInterruptSource', () => {

it('should not emit onInterrupt event on webkit fake mousemove events', fakeAsync(() => {
const source = new DocumentInterruptSource('mousemove');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -64,6 +83,7 @@ describe('core/DocumentInterruptSource', () => {

it('should emit onInterrupt event on webkit real mousemove events', fakeAsync(() => {
const source = new DocumentInterruptSource('mousemove');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand Down
2 changes: 1 addition & 1 deletion projects/core/src/lib/documentinterruptsource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
*/
export class DocumentInterruptSource extends EventTargetInterruptSource {
constructor(events: string, options?: number | EventTargetInterruptOptions) {
super(document.documentElement, events, options);
super(() => document.documentElement, events, options);
}

/*
Expand Down
23 changes: 23 additions & 0 deletions projects/core/src/lib/eventtarget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface NodeStyleEventEmitter {
addListener: (eventName: string | symbol, handler: NodeEventHandler) => this;
removeListener: (eventName: string | symbol, handler: NodeEventHandler) => this;
}
export declare type NodeEventHandler = (...args: any[]) => void;
export interface NodeCompatibleEventEmitter {
addListener: (eventName: string, handler: NodeEventHandler) => void | {};
removeListener: (eventName: string, handler: NodeEventHandler) => void | {};
}
export interface JQueryStyleEventEmitter {
on: (eventName: string, handler: () => void) => void;
off: (eventName: string, handler: () => void) => void;
}
export interface HasEventTargetAddRemove<E> {
addEventListener(type: string, listener: ((evt: E) => void) | null, options?: boolean | AddEventListenerOptions): void;
removeEventListener(type: string, listener?: ((evt: E) => void) | null, options?: EventListenerOptions | boolean): void;
}
export declare type EventTargetLike<T> =
HasEventTargetAddRemove<T> |
NodeStyleEventEmitter |
NodeCompatibleEventEmitter |
JQueryStyleEventEmitter;
export declare type EventTarget<T> = EventTargetLike<T> | ArrayLike<EventTargetLike<T>>;
36 changes: 29 additions & 7 deletions projects/core/src/lib/eventtargetinterruptsource.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { fakeAsync, tick } from '@angular/core/testing';

import { EventTarget } from './eventtarget';
import { EventTargetInterruptSource } from './eventtargetinterruptsource';

describe('core/EventTargetInterruptSource', () => {
it('emits onInterrupt event when attached and event is fired', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -18,6 +20,7 @@ describe('core/EventTargetInterruptSource', () => {

it('emits onInterrupt event when multiple events are specified and one is triggered', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click touch');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -31,6 +34,7 @@ describe('core/EventTargetInterruptSource', () => {

it('does not emit onInterrupt event when detached and event is fired', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click');
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();

// make it interesting by attaching and detaching
Expand All @@ -43,8 +47,25 @@ describe('core/EventTargetInterruptSource', () => {
expect(source.onInterrupt.emit).not.toHaveBeenCalled();
}));

it('does not emit onInterrupt event when running on a server', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click');
const options = { platformId: 'server' as unknown as object };
source.initialize(options);
spyOn(source.onInterrupt, 'emit').and.callThrough();

source.attach();

const expected = new Event('click');
document.body.dispatchEvent(expected);

expect(source.onInterrupt.emit).not.toHaveBeenCalled();

source.detach();
}));

it('should throttle target events using the specified throttleDelay value', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click', 500);
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand Down Expand Up @@ -77,6 +98,7 @@ describe('core/EventTargetInterruptSource', () => {

it('should not throttle target events if throttleDelay is 0', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click', 0);
source.initialize();
spyOn(source.onInterrupt, 'emit').and.callThrough();
source.attach();

Expand All @@ -95,7 +117,7 @@ describe('core/EventTargetInterruptSource', () => {
}));

it('should set default options', () => {
const target = {};
const target = {} as EventTarget<any>;
const source = new EventTargetInterruptSource(target, 'click');
const { throttleDelay, passive } = source.options;

Expand All @@ -104,7 +126,7 @@ describe('core/EventTargetInterruptSource', () => {
});

it('should set passive flag', () => {
const target = {};
const target = {} as EventTarget<any>;
const source = new EventTargetInterruptSource(target, 'click', {
passive: true
});
Expand All @@ -115,7 +137,7 @@ describe('core/EventTargetInterruptSource', () => {
});

it('should set throttleDelay', () => {
const target = {};
const target = {} as EventTarget<any>;
const source = new EventTargetInterruptSource(target, 'click', {
throttleDelay: 1000
});
Expand All @@ -125,11 +147,11 @@ describe('core/EventTargetInterruptSource', () => {
expect(throttleDelay).toBe(1000);
});

it('should set both options', () => {
const target = {};
it('should set all options', () => {
const target = {} as EventTarget<any>;
const source = new EventTargetInterruptSource(target, 'click', {
throttleDelay: 1000,
passive: true
passive: true,
throttleDelay: 1000
});
const { throttleDelay, passive } = source.options;

Expand Down
41 changes: 27 additions & 14 deletions projects/core/src/lib/eventtargetinterruptsource.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { isPlatformServer } from '@angular/common';
import { Observable, Subscription, fromEvent, merge } from 'rxjs';
import { filter, throttleTime } from 'rxjs/operators';

import { EventTarget } from './eventtarget';
import { InterruptArgs } from './interruptargs';
import { InterruptOptions } from './interruptoptions';
import { InterruptSource } from './interruptsource';

/**
Expand Down Expand Up @@ -32,32 +35,39 @@ export class EventTargetInterruptSource extends InterruptSource {
protected passive: boolean;

constructor(
protected target: any,
protected target: EventTarget<any> | (() => EventTarget<any>),
protected events: string,
options?: number | EventTargetInterruptOptions
private opts?: number | EventTargetInterruptOptions
) {
super(null, null);

if (typeof options === 'number') {
options = { throttleDelay: options, passive: false };
if (typeof this.opts === 'number') {
this.opts = { throttleDelay: this.opts, passive: false };
}

options = options || {
throttleDelay: defaultThrottleDelay,
passive: false
this.opts = this.opts || {
passive: false,
throttleDelay: defaultThrottleDelay
};

if (options.throttleDelay === undefined || options.throttleDelay === null) {
options.throttleDelay = defaultThrottleDelay;
if (this.opts.throttleDelay === undefined || this.opts.throttleDelay === null) {
this.opts.throttleDelay = defaultThrottleDelay;
}

this.throttleDelay = options.throttleDelay;
this.passive = !!options.passive;
this.throttleDelay = this.opts.throttleDelay;
this.passive = !!this.opts.passive;
}

initialize(options?: InterruptOptions) {
if (options?.platformId && isPlatformServer(options.platformId)) {
return;
}

const eventTarget = typeof this.target === 'function' ? this.target() : this.target;
const opts = this.passive ? { passive: true } : null;
const fromEvents = events
const fromEvents = this.events
.split(' ')
.map(eventName => fromEvent<any>(target, eventName, opts));
.map(eventName => fromEvent(eventTarget, eventName, opts));
this.eventSrc = merge(...fromEvents);
this.eventSrc = this.eventSrc.pipe(
filter(innerArgs => !this.filterEvent(innerArgs))
Expand Down Expand Up @@ -89,6 +99,9 @@ export class EventTargetInterruptSource extends InterruptSource {
* @return The current option values.
*/
get options(): EventTargetInterruptOptions {
return { throttleDelay: this.throttleDelay, passive: this.passive };
return {
passive: this.passive,
throttleDelay: this.throttleDelay
};
}
}
11 changes: 8 additions & 3 deletions projects/core/src/lib/idle.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
EventEmitter,
Inject,
Injectable,
NgZone,
OnDestroy,
Optional
Optional,
PLATFORM_ID
} from '@angular/core';

import { IdleExpiry } from './idleexpiry';
Expand Down Expand Up @@ -59,7 +61,9 @@ export class Idle implements OnDestroy {
constructor(
private expiry: IdleExpiry,
private zone: NgZone,
@Optional() keepaliveSvc?: KeepaliveSvc
@Optional() keepaliveSvc?: KeepaliveSvc,
// tslint:disable-next-line: ban-types platform id injection will fail with any other type
@Optional() @Inject(PLATFORM_ID) private platformId?: Object
) {
if (keepaliveSvc) {
this.keepaliveSvc = keepaliveSvc;
Expand Down Expand Up @@ -175,7 +179,8 @@ export class Idle implements OnDestroy {
const self = this;

for (const source of sources) {
const sub = new Interrupt(source);
const options = { platformId: this.platformId };
const sub = new Interrupt(source, options);
sub.subscribe((args: InterruptArgs) => {
self.interrupt(args.force, args.innerArgs);
});
Expand Down
7 changes: 6 additions & 1 deletion projects/core/src/lib/interrupt.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Subscription } from 'rxjs';

import { InterruptArgs } from './interruptargs';
import { InterruptOptions } from './interruptoptions';
import { InterruptSource } from './interruptsource';

/*
Expand All @@ -9,7 +10,11 @@ import { InterruptSource } from './interruptsource';
export class Interrupt {
private sub: Subscription;

constructor(public source: InterruptSource) {}
constructor(public source: InterruptSource, options?: InterruptOptions) {
if (source.initialize) {
source.initialize(options);
}
}

/*
* Subscribes to the interrupt using the specified function.
Expand Down
3 changes: 3 additions & 0 deletions projects/core/src/lib/interruptoptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface InterruptOptions {
platformId: object;
}
1 change: 1 addition & 0 deletions projects/core/src/lib/interruptsource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('core/InterruptSource', () => {

it('emits onInterrupt event outside the angular zone', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click');
source.initialize();
const fakeNgZone = Zone.current.fork({
name: 'angular',
properties: {
Expand Down
3 changes: 3 additions & 0 deletions projects/core/src/lib/interruptsource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { EventEmitter } from '@angular/core';

import { InterruptArgs } from './interruptargs';
import { InterruptOptions } from './interruptoptions';

declare const Zone: any;

Expand Down Expand Up @@ -48,4 +49,6 @@ export abstract class InterruptSource {

this.isAttached = false;
}

initialize?(options?: InterruptOptions): void;
}
Loading

0 comments on commit 01cff2e

Please sign in to comment.