Skip to content

Commit

Permalink
feat: 🎸 improve location$ implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
streamich committed May 2, 2020
1 parent ed69dd2 commit 97591ec
Show file tree
Hide file tree
Showing 2 changed files with 171 additions and 47 deletions.
127 changes: 92 additions & 35 deletions src/location$.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
import {location$} from './location$';
import {location$, LocationFields, HistoryFields} from './location$';
const {window, _listeners} = require('./window');

type Listener = {event: string, listener: (...args: any) => void};
type Listener = {event: string; listener: (...args: any) => void};

const listeners: Listener[] = _listeners;

jest.mock('./window', () => {
const listeners: Listener[] = [];
const removedListeners: Listener[] = [];
const location: Location = {
const location: LocationFields = {
hash: '',
host: 'google.com',
href: 'http://google.com'
} as Location;
href: 'http://google.com',
hostname: '',
origin: '',
pathname: '',
port: '',
protocol: '',
search: '',
};
const history: HistoryFields = {
length: 0,
state: null,
};
const wnd = {
location,
history,
addEventListener: (event, listener) => {
listeners.push({event, listener});
},
Expand All @@ -34,61 +45,107 @@ test('can subscribe', () => {
});

test('attaches 3 listeners', async () => {
expect(listeners.length).toBe(3);
expect(listeners.length).toBe(3);
});

test('fires on listener', async () => {
test('emits immediately on subscription', async () => {
const spy = jest.fn();

expect(spy).toHaveBeenCalledTimes(0);
location$.subscribe(spy);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"event": "load",
"hash": "",
"host": "google.com",
"hostname": "",
"href": "http://google.com",
"length": 0,
"origin": "",
"pathname": "",
"port": "",
"protocol": "",
"search": "",
"state": null,
}
`);
});

expect(spy).toHaveBeenCalledTimes(0);

test('does not emit first time if href has not changed', async () => {
const spy = jest.fn();
location$.subscribe(spy);
expect(spy).toHaveBeenCalledTimes(1);
const event = new Event('popstate');
listeners[0].listener(event);

expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toBe(window.location);
});

test('emits again on location change', async () => {
test('emits when href changes', async () => {
const spy = jest.fn();

location$.subscribe(spy);

expect(spy).toHaveBeenCalledTimes(0);

const event1 = new Event('popstate');
listeners[0].listener(event1);

expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toBe(window.location);

window.location.hash = 'asdf';
window.location.href = 'http://google.com#asdf';

const event2 = new Event('popstate');
listeners[0].listener(event2);

const event = new Event('popstate');
listeners[0].listener(event);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[1][0]).toBe(window.location);
});

test('does not emit if location did not change', async () => {
test('emits every time location changes to new href with the latest up-to-date state', async () => {
const spy = jest.fn();

location$.subscribe(spy);

expect(spy).toHaveBeenCalledTimes(0);

window.location.hash = 'foo';
window.location.href = 'http://google.com#foo';
const event1 = new Event('popstate');
listeners[0].listener(event1);

expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][0]).toBe(window.location);
expect(spy).toHaveBeenCalledTimes(2);
expect(spy.mock.calls[1][0]).toMatchInlineSnapshot(`
Object {
"event": "pop",
"hash": "foo",
"host": "google.com",
"hostname": "",
"href": "http://google.com#foo",
"length": 0,
"origin": "",
"pathname": "",
"port": "",
"protocol": "",
"search": "",
"state": null,
}
`);

const event2 = new Event('popstate');
const event3 = new Event('popstate');
listeners[0].listener(event2);
listeners[0].listener(event3);
expect(spy).toHaveBeenCalledTimes(2);

expect(spy).toHaveBeenCalledTimes(1);
window.location.hash = 'bar';
window.location.href = 'http://google.com#bar';
const event4 = new Event('popstate');
listeners[0].listener(event4);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy.mock.calls[2][0]).toMatchInlineSnapshot(`
Object {
"event": "pop",
"hash": "bar",
"host": "google.com",
"hostname": "",
"href": "http://google.com#bar",
"length": 0,
"origin": "",
"pathname": "",
"port": "",
"protocol": "",
"search": "",
"state": null,
}
`);
});

test('still only 3 listeners are attached to window global', async () => {
expect(listeners.length).toBe(3);
});
91 changes: 79 additions & 12 deletions src/location$.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,83 @@
import { merge, from, fromEvent, Observable } from 'rxjs';
import { map, share, distinctUntilChanged } from 'rxjs/operators';
import { BehaviorSubject, merge, from, fromEvent, Observable } from 'rxjs';
import { map, share, distinctUntilChanged, filter } from 'rxjs/operators';
import { window } from './window';

export const location$: Observable<Location> = !!window
? merge(
fromEvent(window, 'popstate'),
fromEvent(window, 'pushstate'),
fromEvent(window, 'replacestate'),
const patchHistoryMethod = (method: 'pushState' | 'replaceState', eventName: string) => {
const original = history[method];
history[method] = function (state) {
// tslint:disable-next-line
const result = original.apply(this, arguments as any);
const event = new Event(eventName);
(event as any).state = state;
window!.dispatchEvent(event);
return result;
};
};

if (!!window) {
patchHistoryMethod('pushState', 'rx-pushstate');
patchHistoryMethod('replaceState', 'rx-replacestate');
}

export type LocationFields = Pick<Location, 'hash' | 'host' | 'hostname' | 'href' | 'origin' | 'pathname' | 'port' | 'protocol' | 'search'>;
export type HistoryFields = Pick<History, 'state' | 'length'>;
export type LocationStateEvent = 'load' | 'pop' | 'push' | 'replace';

export interface LocationState extends LocationFields, HistoryFields {
event: LocationStateEvent;
}

const buildState = (event: LocationStateEvent, location: LocationFields, history: HistoryFields): LocationState => {
const { state, length } = history;
const { hash, host, hostname, href, origin, pathname, port, protocol, search } = location;
return {
event,
state,
length,
hash,
host,
hostname,
href,
origin,
pathname,
port,
protocol,
search,
};
};

const createBrowserLocation$ = (): BehaviorSubject<LocationState> => {
const location$ = new BehaviorSubject(buildState('load', window!.location, window!.history));
merge<LocationStateEvent>(
fromEvent(window!, 'popstate').pipe(map(() => 'pop')) as Observable<LocationStateEvent>,
fromEvent(window!, 'rx-pushstate').pipe(map(() => 'push')) as Observable<LocationStateEvent>,
fromEvent(window!, 'rx-replacestate').pipe(map(() => 'replace')) as Observable<LocationStateEvent>,
).pipe(
share(),
map(() => window!.location.href),
distinctUntilChanged((href1, href2) => href1 === href2),
map(() => window!.location)
)
: from([]);
filter(() => window!.location.href !== location$.getValue().href),
// map<LocationStateEvent, [LocationStateEvent, string]>(event => [event, window!.location.href]),
// distinctUntilChanged(([, href1], [, href2]) => href1 === href2),
map(event => buildState(event, window!.location, window!.history))
).subscribe(location$);
return location$;
};

const createServerLocation$ = (): BehaviorSubject<LocationState> =>
new BehaviorSubject(buildState('load', {
hash: '',
host: '',
hostname: '',
href: '',
origin: '',
pathname: '',
port: '',
protocol: '',
search: '',
}, {
length: 0,
state: null,
}));

export const location$: BehaviorSubject<LocationState> = !!window
? createBrowserLocation$()
: createServerLocation$();

0 comments on commit 97591ec

Please sign in to comment.