Skip to content

Commit

Permalink
Add HashProtocol
Browse files Browse the repository at this point in the history
  • Loading branch information
taion committed Dec 30, 2017
1 parent c198b81 commit de51f7d
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 0 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ const protocol = new BrowserProtocol();

The examples here assume the use of a `new BrowserProtocol()`.

#### `HashProtocol`

`HashProtocol` uses the URL hash for navigation, and is intended for use in cases where server-side routing is not available, or in legacy environments where the HTML5 History API is not available. Prefer using `BrowserProtocol` over `HashProtocol` when possible.

```js
const protocol = new HashProtocol();
```

#### `ServerProtocol`

`ServerProtocol` uses a fixed, in-memory location for use in server-side rendering. It takes the path for the location to use. `ServerProtocol` instances do not support `location.state` and cannot navigate.
Expand Down
82 changes: 82 additions & 0 deletions src/HashProtocol.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import off from 'dom-helpers/events/off';
import on from 'dom-helpers/events/on';
import invariant from 'invariant';

import StateStorage from './StateStorage';
import createPath from './utils/createPath';
import ensureLocation from './utils/ensureLocation';

export default class HashProtocol {
constructor() {
this.stateStorage = new StateStorage(this, '@@farce');

this._index = null;
this._numExpectedHashChanges = 0;
}

init() {
// TODO: Do we still need to work around the old Firefox bug here?
const location = ensureLocation(window.location.hash.slice(1) || '/');

const { index = 0, state } = this.stateStorage.read(location, null) || {};
const delta = this._index != null ? index - this._index : 0;
this._index = index;

return {
action: 'POP',
...location,
index,
delta,
state,
};
}

subscribe(listener) {
const onHashChange = () => {
// Ignore hash change events triggered by our own navigation.
if (this._numExpectedHashChanges > 0) {
--this._numExpectedHashChanges;
return;
}

listener(this.init());
};

on(window, 'hashchange', onHashChange);
return () => off(window, 'hashchange', onHashChange);
}

transition(location) {
const { action, state } = location;

const push = action === 'PUSH';
invariant(
push || action === 'REPLACE',
`Unrecognized browser protocol action ${action}.`,
);

const delta = push ? 1 : 0;
this._index += delta;

const path = createPath(location);

++this._numExpectedHashChanges;
if (push) {
window.location.hash = path;
} else {
window.location.replace(`#${path}`);
}

this.stateStorage.save(location, null, { index: this._index, state });

return { ...location, index: this._index, delta };
}

go(delta) {
window.history.go(delta);
}

createHref(location) {
return `#${createPath(location)}`;
}
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export createQueryMiddleware from './createQueryMiddleware';
export createStoreHistory from './createStoreHistory';
export createTransitionHookMiddleware from './createTransitionHookMiddleware';
export ensureLocationMiddleware from './ensureLocationMiddleware';
export HashProtocol from './HashProtocol';
export locationReducer from './locationReducer';
export MemoryProtocol from './MemoryProtocol';
export queryMiddleware from './queryMiddleware';
Expand Down
1 change: 1 addition & 0 deletions test/BrowserProcotol.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ describe('BrowserProtocol', () => {
index: 2,
delta: 0,
});
await timeout(20);

expect(window.location.pathname).to.equal('/qux');
expect(listener).not.to.have.been.called();
Expand Down
176 changes: 176 additions & 0 deletions test/HashProcotol.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import HashProtocol from '../src/HashProtocol';

import { timeout } from './helpers';

describe('HashProtocol', () => {
beforeEach(() => {
window.history.replaceState(null, null, '/');
});

it('should parse the initial location', () => {
window.history.replaceState(
null,
null,
'/pathname?search#/foo?bar=baz#qux',
);
const protocol = new HashProtocol();

expect(protocol.init()).to.eql({
action: 'POP',
pathname: '/foo',
search: '?bar=baz',
hash: '#qux',
index: 0,
delta: 0,
state: undefined,
});
});

it('should support basic navigation', async () => {
const protocol = new HashProtocol();

const listener = sinon.spy();
protocol.subscribe(listener);

const barLocation = protocol.transition({
action: 'PUSH',
pathname: '/bar',
search: '?search',
hash: '#hash',
state: { the: 'state' },
});

expect(window.location.hash).to.equal('#/bar?search#hash');
expect(barLocation).to.deep.include({
action: 'PUSH',
pathname: '/bar',
search: '?search',
hash: '#hash',
index: 1,
delta: 1,
state: { the: 'state' },
});

expect(
protocol.transition({
action: 'PUSH',
pathname: '/baz',
search: '',
hash: '',
}),
).to.include({
action: 'PUSH',
pathname: '/baz',
index: 2,
delta: 1,
});

expect(window.location.hash).to.equal('#/baz');

expect(
protocol.transition({
action: 'REPLACE',
pathname: '/qux',
search: '',
hash: '',
}),
).to.include({
action: 'REPLACE',
pathname: '/qux',
index: 2,
delta: 0,
});
await timeout(20);

expect(window.location.hash).to.equal('#/qux');
expect(listener).not.to.have.been.called();

if (window.navigator.userAgent.includes('Firefox')) {
// Firefox triggers a full page reload on hash pops.
return;
}

protocol.go(-1);
await timeout(20);

expect(window.location.hash).to.equal('#/bar?search#hash');
expect(listener).to.have.been.calledOnce();
expect(listener.firstCall.args[0]).to.deep.include({
action: 'POP',
pathname: '/bar',
search: '?search',
hash: '#hash',
index: 1,
delta: -1,
state: { the: 'state' },
});
listener.reset();

window.history.back();
await timeout(20);

expect(window.location.hash).to.be.empty();
expect(listener).to.have.been.calledOnce();
expect(listener.firstCall.args[0]).to.deep.include({
action: 'POP',
pathname: '/',
index: 0,
delta: -1,
state: undefined,
});
listener.reset();
});

it('should support subscribing and unsubscribing', async () => {
const protocol = new HashProtocol('/foo');
protocol.transition({
action: 'PUSH',
pathname: '/bar',
search: '',
hash: '',
});
protocol.transition({
action: 'PUSH',
pathname: '/baz',
search: '',
hash: '',
});

const listener = sinon.spy();
const unsubscribe = protocol.subscribe(listener);

if (window.navigator.userAgent.includes('Firefox')) {
// Firefox triggers a full page reload on hash pops.
return;
}

protocol.go(-1);
await timeout(20);

expect(listener).to.have.been.calledOnce();
expect(listener.firstCall.args[0]).to.include({
action: 'POP',
pathname: '/bar',
});
listener.reset();

unsubscribe();

protocol.go(-1);
await timeout(20);

expect(listener).not.to.have.been.called();
});

it('should support createHref', () => {
const protocol = new HashProtocol();

expect(
protocol.createHref({
pathname: '/foo',
search: '?bar=baz',
hash: '#qux',
}),
).to.equal('#/foo?bar=baz#qux');
});
});

0 comments on commit de51f7d

Please sign in to comment.