Skip to content

Commit

Permalink
feat(rxjs-core): add isPageVisible$()
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed Aug 12, 2023
1 parent ce1a8bb commit ad1bd8d
Show file tree
Hide file tree
Showing 12 changed files with 268 additions and 102 deletions.
5 changes: 5 additions & 0 deletions projects/integration/src/app/api-tests/ng-dev.spec.ts
Expand Up @@ -7,6 +7,7 @@ import {
expectCallsAndReset,
expectRequest,
expectSingleCallAndReset,
IsPageVisibleHarness,
logTimers,
marbleTest,
SlTestRequest,
Expand All @@ -31,6 +32,10 @@ describe('ng-dev', () => {
expect(ComponentHarnessSuperclass).toBeDefined();
});

it('has IsPageVisibleHarness', () => {
expect(IsPageVisibleHarness).toBeDefined();
});

it('has TestCall', () => {
expect(TestCall).toBeDefined();
});
Expand Down
123 changes: 63 additions & 60 deletions projects/integration/src/app/api-tests/rxjs-core.spec.ts
Expand Up @@ -5,6 +5,7 @@ import {
delayOnMicrotaskQueue,
distinctUntilKeysChanged,
filterBehavior,
isPageVisible$,
keepWakeLock$,
logValues,
mapAndCacheArrayElements,
Expand All @@ -17,65 +18,67 @@ import {
} from '@s-libs/rxjs-core';

describe('rxjs-core', () => {
describe('public API', () => {
it('has SubscriptionManager', () => {
expect(SubscriptionManager).toBeDefined();
});

it('has mapAndCacheArrayElements', () => {
expect(mapAndCacheArrayElements).toBeDefined();
});

it('has mapAndCacheObjectElements', () => {
expect(mapAndCacheObjectElements).toBeDefined();
});

it('has cache', () => {
expect(cache).toBeDefined();
});

it('has createOperatorFunction', () => {
expect(createOperatorFunction).toBeDefined();
});

it('has debounceMap', () => {
expect(debounceMap).toBeDefined();
});

it('has delayOnMicrotaskQueue', () => {
expect(delayOnMicrotaskQueue).toBeDefined();
});

it('has distinctUntilKeysChanged', () => {
expect(distinctUntilKeysChanged).toBeDefined();
});

it('has filterBehavior', () => {
expect(filterBehavior).toBeDefined();
});

it('has keepWakeLock$', () => {
expect(keepWakeLock$).toBeDefined();
});

it('has logValues', () => {
expect(logValues).toBeDefined();
});

it('has mapToLatestFrom', () => {
expect(mapToLatestFrom).toBeDefined();
});

it('has mixInSubscriptionManager', () => {
expect(mixInSubscriptionManager).toBeDefined();
});

it('has skipAfter', () => {
expect(skipAfter).toBeDefined();
});

it('has withHistory', () => {
expect(withHistory).toBeDefined();
});
it('has SubscriptionManager', () => {
expect(SubscriptionManager).toBeDefined();
});

it('has mapAndCacheArrayElements', () => {
expect(mapAndCacheArrayElements).toBeDefined();
});

it('has mapAndCacheObjectElements', () => {
expect(mapAndCacheObjectElements).toBeDefined();
});

it('has cache', () => {
expect(cache).toBeDefined();
});

it('has createOperatorFunction', () => {
expect(createOperatorFunction).toBeDefined();
});

it('has debounceMap', () => {
expect(debounceMap).toBeDefined();
});

it('has delayOnMicrotaskQueue', () => {
expect(delayOnMicrotaskQueue).toBeDefined();
});

it('has distinctUntilKeysChanged', () => {
expect(distinctUntilKeysChanged).toBeDefined();
});

it('has filterBehavior', () => {
expect(filterBehavior).toBeDefined();
});

it('has isPageVisible$', () => {
expect(isPageVisible$).toBeDefined();
});

it('has keepWakeLock$', () => {
expect(keepWakeLock$).toBeDefined();
});

it('has logValues', () => {
expect(logValues).toBeDefined();
});

it('has mapToLatestFrom', () => {
expect(mapToLatestFrom).toBeDefined();
});

it('has mixInSubscriptionManager', () => {
expect(mixInSubscriptionManager).toBeDefined();
});

it('has skipAfter', () => {
expect(skipAfter).toBeDefined();
});

it('has withHistory', () => {
expect(withHistory).toBeDefined();
});
});
1 change: 1 addition & 0 deletions projects/ng-dev/src/lib/rxjs-core-harnesses/index.ts
@@ -0,0 +1 @@
export { IsPageVisibleHarness } from './is-page-visible.harness';
@@ -0,0 +1,49 @@
import { Component, DoCheck } from '@angular/core';
import { isPageVisible$ } from '@s-libs/rxjs-core';
import { ComponentContext } from '../component-context';
import { IsPageVisibleHarness } from './is-page-visible.harness';

describe('IsPageVisibleHarness', () => {
it('triggers change detection in an `AngularContext`', () => {
@Component({ template: 'Hi mom' })
class TestComponent implements DoCheck {
change = jasmine.createSpy();

ngDoCheck(): void {
this.change();
}
}

const ctx = new ComponentContext(TestComponent);
ctx.run(async () => {
const isPageVisibleHarness = new IsPageVisibleHarness();
const { change } = ctx.getComponentInstance();
change.calls.reset();

isPageVisibleHarness.setVisible(false);
expect(change).toHaveBeenCalled();
});
});

describe('example from the docs', () => {
it('works for example 1', () => {
const isPageVisibleHarness = new IsPageVisibleHarness();
isPageVisibleHarness.setVisible(false);

const next = jasmine.createSpy();
isPageVisible$().subscribe(next);
expect(next).toHaveBeenCalledWith(false);

isPageVisibleHarness.setVisible(true);
expect(next).toHaveBeenCalledWith(true);
});

it('works for example 2', () => {
const isPageVisibleHarness = new IsPageVisibleHarness();
expect(document.visibilityState).toBe('visible');

isPageVisibleHarness.setVisible(false);
expect(document.visibilityState).toBe('hidden');
});
});
});
@@ -0,0 +1,54 @@
import { AngularContext } from '../angular-context';

/**
* Use to control {@link isPageVisible$()} in tests. Create only one per test, before anything calls `isPageVisible$()`.
*
* ```ts
* const isPageVisibleHarness = new IsPageVisibleHarness();
* isPageVisibleHarness.setVisible(false);
*
* const next = jasmine.createSpy();
* isPageVisible$().subscribe(next);
* expect(next).toHaveBeenCalledWith(false);
*
* isPageVisibleHarness.setVisible(true);
* expect(next).toHaveBeenCalledWith(true);
* ```
*
* It also stubs `document.visibilityState` to match.
* ```ts
* const isPageVisibleHarness = new IsPageVisibleHarness();
* expect(document.visibilityState).toBe('visible');
*
* isPageVisibleHarness.setVisible(false);
* expect(document.visibilityState).toBe('hidden');
* ```
*/
export class IsPageVisibleHarness {
#visibilityState: jasmine.Spy;
#notifyVisibilityChange: VoidFunction | undefined;

constructor() {
this.#visibilityState = spyOnProperty(
document,
'visibilityState',
).and.returnValue('visible');

spyOn(document, 'addEventListener')
.withArgs('visibilitychange', jasmine.anything(), undefined)
.and.callFake(
(_: string, handler: EventListenerOrEventListenerObject) => {
this.#notifyVisibilityChange = handler as VoidFunction;
},
);
}

/**
* Sets the page's visibility state, and triggers any subscriptions to `isPageVisible$()`. Automatically triggers change detection if running with an {@linkcode AngularContext}.
*/
setVisible(visible: boolean): void {
this.#visibilityState.and.returnValue(visible ? 'visible' : 'hidden');
this.#notifyVisibilityChange?.();
AngularContext.getCurrent()?.tick();
}
}
9 changes: 5 additions & 4 deletions projects/ng-dev/src/public-api.ts
Expand Up @@ -2,11 +2,12 @@
* Public API Surface of ng-dev
*/

export * from './lib/angular-context';
export * from './lib/component-context';
export * from './lib/rxjs-core-harnesses';
export * from './lib/spies';
export * from './lib/angular-context/index';
export * from './lib/component-context/index';
export * from './lib/test-requests/index';
export { ComponentHarnessSuperclass } from './lib/component-harness/component-harness-superclass';
export * from './lib/test-requests';
export * from './lib/component-harness/component-harness-superclass';
export { logTimers } from './lib/log-timers/log-timers';
export { marbleTest } from './lib/marble-test/marble-test';
export { staticTest } from './lib/static-test/static-test';
1 change: 1 addition & 0 deletions projects/rxjs-core/src/lib/devtools/index.ts
@@ -0,0 +1 @@
export { logToReduxDevtoolsExtension } from './log-to-redux-devtools-extension';
3 changes: 2 additions & 1 deletion projects/rxjs-core/src/lib/index.ts
@@ -1,7 +1,8 @@
export * from './devtools';
export * from './operators';
export { createOperatorFunction } from './create-operator-function';
export { isPageVisible$ } from './is-page-visible';
export { keepWakeLock$ } from './keep-wake-lock';
export { logToReduxDevtoolsExtension } from './devtools/log-to-redux-devtools-extension';
export {
mixInSubscriptionManager,
SubscriptionManager,
Expand Down
45 changes: 45 additions & 0 deletions projects/rxjs-core/src/lib/is-page-visible.spec.ts
@@ -0,0 +1,45 @@
import { expectSingleCallAndReset, IsPageVisibleHarness } from '@s-libs/ng-dev';
import { isPageVisible$ } from './is-page-visible';

describe('isPageVisible$()', () => {
let harness: IsPageVisibleHarness;
beforeEach(() => {
harness = new IsPageVisibleHarness();
});

it('emits changes to page visibility', () => {
const next = jasmine.createSpy();
isPageVisible$().subscribe(next);
next.calls.reset();

harness.setVisible(false);
expectSingleCallAndReset(next, false);

harness.setVisible(true);
expectSingleCallAndReset(next, true);
});

it('emits immediately upon subscription', () => {
const next1 = jasmine.createSpy();
isPageVisible$().subscribe(next1);
expectSingleCallAndReset(next1, true);

harness.setVisible(false);
const next2 = jasmine.createSpy();
isPageVisible$().subscribe(next2);
expectSingleCallAndReset(next2, false);
});

it('is quiet about events that do not change visibility', () => {
const next = jasmine.createSpy();
isPageVisible$().subscribe(next);

harness.setVisible(true);
harness.setVisible(true);
expectSingleCallAndReset(next, true);

harness.setVisible(false);
harness.setVisible(false);
expectSingleCallAndReset(next, false);
});
});
25 changes: 25 additions & 0 deletions projects/rxjs-core/src/lib/is-page-visible.ts
@@ -0,0 +1,25 @@
import { distinctUntilChanged, fromEvent, Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';

/**
* Creates an observable that emits when the [page visibility]{@link https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API} changes. It also emits the current visibility immediately upon subscription.
*
* ```ts
* isPageVisible$().subscribe((isVisible) => {
* if (isVisible) {
* console.log('Page is visible');
* } else {
* console.log('Page is hidden');
* }
* });
* ```
*
* Note that for Angular projects, there is a harness available to help with tests that use this function in `@s-libs/ng-dev`.
*/
export function isPageVisible$(): Observable<boolean> {
return fromEvent(document, 'visibilitychange').pipe(
startWith(undefined),
map(() => document.visibilityState === 'visible'),
distinctUntilChanged(),
);
}

0 comments on commit ad1bd8d

Please sign in to comment.