-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
268 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)}`; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); |