Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(interruptsource): allow interrupts to use an ssr option #130

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Expand Up @@ -94,6 +94,49 @@ 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`) can receive an option when they are created called `ssr`. It will be your app's responsibility to detect whether your app is running in the browser or on the server by using [`isPlatformServer`](https://angular.io/api/common/isPlatformServer) (or inverting [`isPlatformBrowser`](https://angular.io/api/common/isPlatformBrowser), if you'd prefer) when creating your interrupt sources.

```
import { Component, PLATFORM_ID, Inject } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

import { Idle, DocumentInterruptSource, createDefaultInterruptSources } from '@ng-idle/core';

@Component({
selector: 'app-root',
templateUrl: './postfeed.component.html',
styleUrls: ['./postfeed.component.css']
})
export class AppComponent implements OnInit {

constructor(@Inject(PLATFORM_ID) private platform: Object, private idle: Idle) { }

ngOnInit() {
// configure idle normally
this.idle.setIdle(60 * 10);

// create your interrupts. DO NOT use DEFAULT_INTERRUPTSOURCES for ssr/univeral apps!
const myInterrupts = new DocumentInterruptSource('mousedown', { ssr: isPlatformServer(platform) });
// OR, you can use createDefaultInterruptSources and pass in the ssr option
// const myInterrupts = createDefaultInterruptSources({ ssr: isPlatformServer(platform) });

// now set the interrupt sources
this.idle.setInterrupts(myInterrupts);

if (isPlatformBrowser(this.platform)) {
// only need to start watching if we're in the browser
this.idle.watch()
}
}
}
```

**Note** `DEFAULT_INTERRUPTSOURCES` should NOT be used by ssr/universal apps. The value of this constant is equivalent to `createDefaultInterruptSources({ssr: false})` and thus will not work in your SSR app. You _may_ continue to use DEFAULT_INTERRUPTSOURCES` if you are **not** using SSR/universal rendering.

## 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
4 changes: 2 additions & 2 deletions projects/core/src/lib/defaultinterruptsources.ts
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();
13 changes: 13 additions & 0 deletions projects/core/src/lib/documentinterruptsource.spec.ts
Expand Up @@ -16,6 +16,19 @@ describe('core/DocumentInterruptSource', () => {
source.detach();
}));

it('does not emit events when ssr option is true', fakeAsync(() => {
const source = new DocumentInterruptSource('click', { ssr: true });
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');
spyOn(source.onInterrupt, 'emit').and.callThrough();
Expand Down
11 changes: 10 additions & 1 deletion projects/core/src/lib/documentinterruptsource.ts
@@ -1,3 +1,6 @@
import { Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

import {
EventTargetInterruptOptions,
EventTargetInterruptSource
Expand All @@ -8,7 +11,13 @@ import {
*/
export class DocumentInterruptSource extends EventTargetInterruptSource {
constructor(events: string, options?: number | EventTargetInterruptOptions) {
super(document.documentElement, events, options);
const target =
options && (options as EventTargetInterruptOptions).ssr
? null
: document.documentElement;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still getting the below compile error when I import NgIdleKeepaliveModule.forRoot() in the app.module of my Angular project, and run the command npm run build:ssr && npm run serve:ssr.

/Users/bfwg/code/ng2-idle-example/dist/server/main.js:112377
            : document.documentElement;
              ^

ReferenceError: document is not defined
    at new DocumentInterruptSource (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:112377:15)
    at createDefaultInterruptSources (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:112522:9)
    at Module.evxF (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:112527:34)
    at __webpack_require__ (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:20:30)
    at Object.V7fC (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:97581:13)
    at __webpack_require__ (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:20:30)
    at Object.K011 (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:86964:37)
    at __webpack_require__ (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:20:30)
    at Object.0 (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:437:18)
    at __webpack_require__ (/Users/bfwg/code/ng2-idle-example/dist/server/main.js:20:30)

main.js:112377 points to this line.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you publish this example code somewhere?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a ssr boilerplate app https://github.com/bfwg/ng2-idle-ssr-example.
Steps to reproduce:

  1. clone the ng2-idle-ssr-example app.
  2. npm install
  3. build core and keepalive module from thie PR.
  4. cope them into the node_modules of the boilerplate app, inside @ng-idle
  5. run npm run build:ssr && npm run serve:ssr command.

Please let me know if you need more information.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bfwg, did you find a solution to your problem? I am having similar errors to yours.


options = !options || typeof options === 'number' ? {} : options;
super(target, events, options);
}

/*
Expand Down
49 changes: 42 additions & 7 deletions projects/core/src/lib/eventtargetinterruptsource.spec.ts
Expand Up @@ -43,6 +43,36 @@ describe('core/EventTargetInterruptSource', () => {
expect(source.onInterrupt.emit).not.toHaveBeenCalled();
}));

it('does not emit onInterrupt event when ssr is true', fakeAsync(() => {
const source = new EventTargetInterruptSource(document.body, 'click', {
ssr: true
});
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('does not emit onInterrupt event when target is null', fakeAsync(() => {
const source = new EventTargetInterruptSource(null, 'click');
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);
spyOn(source.onInterrupt, 'emit').and.callThrough();
Expand Down Expand Up @@ -97,43 +127,48 @@ describe('core/EventTargetInterruptSource', () => {
it('should set default options', () => {
const target = {};
const source = new EventTargetInterruptSource(target, 'click');
const { throttleDelay, passive } = source.options;
const { throttleDelay, passive, ssr } = source.options;

expect(passive).toBeFalsy();
expect(throttleDelay).toBe(500);
expect(ssr).toBeFalsy();
});

it('should set passive flag', () => {
const target = {};
const source = new EventTargetInterruptSource(target, 'click', {
passive: true
});
const { throttleDelay, passive } = source.options;
const { throttleDelay, passive, ssr: isBrowser } = source.options;

expect(passive).toBeTruthy();
expect(throttleDelay).toBe(500);
expect(isBrowser).toBeFalsy();
});

it('should set throttleDelay', () => {
const target = {};
const source = new EventTargetInterruptSource(target, 'click', {
throttleDelay: 1000
});
const { throttleDelay, passive } = source.options;
const { throttleDelay, passive, ssr } = source.options;

expect(passive).toBeFalsy();
expect(throttleDelay).toBe(1000);
expect(ssr).toBeFalsy();
});

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

expect(passive).toBeTruthy();
expect(throttleDelay).toBe(1000);
expect(ssr).toBeTruthy();
});
});
25 changes: 21 additions & 4 deletions projects/core/src/lib/eventtargetinterruptsource.ts
Expand Up @@ -18,6 +18,12 @@ export interface EventTargetInterruptOptions {
* Note: you need to detect if the browser supports passive listeners, and only set this to true if it does.
*/
passive?: boolean;

/**
* Whether or not the app is running on the server side or non-browser context where access
* to browser globals and user input is not possible.
*/
ssr?: boolean;
}

const defaultThrottleDelay = 500;
Expand All @@ -30,6 +36,7 @@ export class EventTargetInterruptSource extends InterruptSource {
private eventSubscription: Subscription = new Subscription();
protected throttleDelay: number;
protected passive: boolean;
protected ssr: boolean;

constructor(
protected target: any,
Expand All @@ -39,12 +46,13 @@ export class EventTargetInterruptSource extends InterruptSource {
super(null, null);

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

options = options || {
throttleDelay: defaultThrottleDelay,
passive: false
passive: false,
ssr: false,
throttleDelay: defaultThrottleDelay
};

if (options.throttleDelay === undefined || options.throttleDelay === null) {
Expand All @@ -53,6 +61,11 @@ export class EventTargetInterruptSource extends InterruptSource {

this.throttleDelay = options.throttleDelay;
this.passive = !!options.passive;
this.ssr = !!options.ssr;

if (this.ssr || !target) {
return;
}

const opts = this.passive ? { passive: true } : null;
const fromEvents = events
Expand Down Expand Up @@ -89,6 +102,10 @@ export class EventTargetInterruptSource extends InterruptSource {
* @return The current option values.
*/
get options(): EventTargetInterruptOptions {
return { throttleDelay: this.throttleDelay, passive: this.passive };
return {
passive: this.passive,
ssr: this.ssr,
throttleDelay: this.throttleDelay
};
}
}
14 changes: 14 additions & 0 deletions projects/core/src/lib/storageinterruptsource.spec.ts
Expand Up @@ -57,4 +57,18 @@ describe('core/StorageInterruptSource', () => {

source.detach();
}));

it('does not emit onInterrupt event when ssr is true', fakeAsync(() => {
const source = new StorageInterruptSource({ ssr: true });
spyOn(source.onInterrupt, 'emit').and.callThrough();

source.attach();

const expected = new StorageEvent('storage');
window.dispatchEvent(expected);

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

source.detach();
}));
});
5 changes: 3 additions & 2 deletions projects/core/src/lib/storageinterruptsource.ts
@@ -1,11 +1,12 @@
import { WindowInterruptSource } from './windowinterruptsource';
import { EventTargetInterruptOptions } from './eventtargetinterruptsource';

/*
* An interrupt source on the storage event of Window.
*/
export class StorageInterruptSource extends WindowInterruptSource {
constructor(throttleDelay = 500) {
super('storage', throttleDelay);
constructor(options: number | EventTargetInterruptOptions = 500) {
super('storage', options);
}

/*
Expand Down
14 changes: 14 additions & 0 deletions projects/core/src/lib/windowinterruptsource.spec.ts
Expand Up @@ -29,4 +29,18 @@ describe('core/WindowInterruptSource', () => {

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

it('does not emit onInterrupt event when ssr is true', fakeAsync(() => {
const source = new WindowInterruptSource('focus', { ssr: true });
spyOn(source.onInterrupt, 'emit').and.callThrough();

source.attach();

const expected = new Event('focus');
window.dispatchEvent(expected);

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

source.detach();
}));
});
5 changes: 4 additions & 1 deletion projects/core/src/lib/windowinterruptsource.ts
Expand Up @@ -8,6 +8,9 @@ import {
*/
export class WindowInterruptSource extends EventTargetInterruptSource {
constructor(events: string, options?: number | EventTargetInterruptOptions) {
super(window, events, options);
const target =
options && (options as EventTargetInterruptOptions).ssr ? null : window;

super(target, events, options);
}
}