Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active May 11, 2018 12:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save loilo/2489e41636e53ef1f3a342b19cdb766b to your computer and use it in GitHub Desktop.
Save loilo/2489e41636e53ef1f3a342b19cdb766b to your computer and use it in GitHub Desktop.
Synchronize shareable key-value state to the window.location.hash as human-readable as possible

Hash State Manager

Allows to synchronize shareable key-value state to the window.location.hash in a format as human-readable as possible.

It serializes and parses the following very simple form of state (TypeScript notation):

{ [key: string]: string[] }

For example the following state:

{
  foo: [ 'bar' ],
  baz: [ 'qux', 'gorge' ]
}

would be serialized as #!foo:bar/baz:qux,gorge and vice versa.

Create & Destroy

Start by creating a HashStateManager instance:

const hsm = new HashStateManager

If you, for whatever reason, want to create a manager for a different window, you can pass that to the constructor:

const hsm = new HashStateManager({ global: window.parent })

By default, the manager reacts to hashes of the form #!.... However, you can change the ! prefix to anything you want:

const hsm = new HashStateManager({ prefix: '+' })

If you stop using the hash state manager, you can destroy it to revert side effects it caused:

hsm.destroy()

Check

Check if any state is available:

hsm.has()

// #!foo:bar -> true
// #foo -> false

Check if a certain key is set:

hsm.has('baz')

// #!foo:bar/baz:qux -> true
// #!foo:bar -> false

Check if a key contains a certain value:

hsm.has('foo', 'bar')

// #!foo:bar -> true
// #!foo:bar,baz -> true
// #!foo:qux -> false

Read

Get the value of a key:

hsm.get('foo')

// #!foo:bar -> [ 'bar' ]

Get the state as a whole:

hsm.get()

// #!foo:bar/baz:qux,gorge -> { foo: [ 'bar' ], baz: [ 'qux', 'gorge' ] }
// #! -> {}

Modify

Set the state as a whole:

hsm.set({ bar: [ 'baz' ] })

// #!bar:baz

Set one property:

hsm.set({ foo: [ 'grault' ] })
// #!foo:bar/baz:qux -> #!foo:grault/baz:qux

hsm.set({ foo: [] })
// #!foo:bar/baz:qux -> #!baz:qux

Add value to a property:

hsm.add('foo', 'baz')
// #! -> #!foo:baz
// #!foo:bar -> #!foo:bar,baz
// #!foo:bar,baz -> #!foo:bar,baz

Remove value from a property:

hsm.remove('foo', 'baz')
// #!foo:bar,baz -> #!foo:bar
// #!foo:baz -> #!
// #!foo:bar -> #!foo:bar

Remove a whole property:

hsm.remove('foo')
// #!foo:bar,baz -> #!

Watch

Be notified when the state changes:

hsm.onChange((newState, oldState) => { ... })

Stop listening to changes:

const unwatch = hsm.onChange((newState, oldState) => { ... })

unwatch()
class HashStateManager {
constructor ({ global = window, prefix = '!' } = {}) {
this.window = global
this.prefix = prefix
this.callbacks = new Set
this.urlListenerEnabled = true
this.boundUrlListener = this.urlListener.bind(this)
this.internalState = this.parseState()
this.publicState = this.createState()
this.window.addEventListener('popstate', this.boundUrlListener)
}
/**
* The listener that's attached for the window's `popstate` event
* @param {PopStateEvent} evt The event object delivered by the `popstate` event
*/
urlListener (evt) {
if (!this.urlListenerEnabled) return
console.log('popstate', evt.state)
if (evt.state && '__hashManager__' in evt.state) {
this.internalState = this.createState(evt.state)
} else {
this.internalState = this.parseState()
}
this.notify()
this.publicState = this.createState()
}
/**
* Reverts all side effects the HashManager instance introduced
*/
destroy () {
this.window.removeEventListener('popstate', this.boundUrlListener)
}
/**
* Create a state object from the given object. Copies the given state and removes any __hashManager__ properties.
* @param {object} state The provided state object
* @return {object} The created state object
*/
createState (state = this.internalState) {
const stateCopy = { ...state }
delete stateCopy.__hashManager__
return stateCopy
}
/**
* Parses the the state from the URL into an object
*/
parseState () {
if (!this.window.location.hash.startsWith('#' + this.prefix)) {
return {}
} else {
return this.window.location.hash
.slice(this.prefix.length + 1)
.split('/')
.reduce((carry, contentGroup) => {
const [ groupName, serializedItems ] = contentGroup.split(':')
const items = serializedItems == null
? null
: serializedItems.split(',')
if (groupName.length) {
return { ...carry, [groupName]: items }
} else {
return carry
}
}, {})
}
}
/**
* Takes the internal state and publishes it to the URL. Also notifies attached listeners.
*/
publishState () {
// Disable the `popstate` listener
this.urlListenerEnabled = false
let hash = Object.entries(this.internalState)
.map(([ group, items ]) => `${group}:${items.join(',')}`)
.join('/')
if (hash.length) {
hash = '#!' + hash
}
this.window.history.pushState({ __hashManager__: true, ...this.internalState }, null, this.window.location.pathname + hash)
// Re-enable the `popstate` listener
this.urlListenerEnabled = true
this.notify()
this.publicState = this.createState()
}
/**
* Checks if there is state for a given key or at all
* @param {string} key The key to check for
* @param {string} value The value key to check for in the key
* @return {boolean} Tells if the value is set for the key. If the value is omitted, it tells if the key exists. If key and value are omitted, it tells if there's any state at all.
*/
has (key, value) {
if (typeof value === 'string') {
return key in this.internalState && this.internalState[key].includes(value)
} else if (typeof key === 'string') {
return key in this.internalState
} else {
return this.window.location.hash.startsWith('#!')
}
}
/**
* Gets the state as a whole or from a given key
* @param {string} key The state (array of strings) of the according key
* @return {object|Array} The state of the key. If the key is omitted, the whole state object is returned.
*/
get (key) {
if (typeof key === 'string') {
return this.internalState[key]
} else {
return this.internalState
}
}
/**
* Adds a value to a new or existing key
* @param {string} key The key to affect
* @param {string} value The value to attach to the key
*/
add (key, value) {
this.validateValue(value)
if (!this.has(key)) {
this.set(key, [ value ])
} else if (!this.internalState[key].includes(value)) {
this.internalState[key].push(value)
this.publishState()
}
}
/**
* Removes a key or a key's value from the state
* @param {string} key The key to affect
* @param {string} value The value to remove from the key. If omitted, the whole key will be discarded.
*/
remove (key, value) {
if (this.has(key)) {
if (typeof value === 'string') {
this.validateValue(value)
if (this.internalState[key].includes(value)) {
this.internalState[key].splice(this.internalState[key].indexOf(value), 1)
if (this.internalState[key].length) {
this.publishState()
} else {
this.set(key, [])
}
}
} else {
this.set(key, [])
}
}
}
/**
* Sets the given key to a value
* @param {string} key The key to define
* @param {Array|string} value The value(s) to set. If an empty array is given, the key will be removed. If the value is omitted, a state object is expected as key.
*/
set (key, value) {
if (typeof key === 'object') {
this.internalState = this.createState(key)
} else {
if (typeof value === 'string') {
value = [ value ]
}
if (Array.isArray(value) && value.length) {
value.forEach(this.validateValue)
this.internalState[key] = value
} else {
delete this.internalState[key]
}
}
this.publishState()
}
/**
* Attaches a listener callback for state changes
* @param {Function} callback A callback that takes the new state object and the old state object as arguments
* @return {Function} A callback to detach the listener from the hash manager
*/
onChange (callback) {
this.callbacks.add(callback)
return () => this.callbacks.delete(callback)
}
/**
* Notifies the attached listeners of a change
*/
notify () {
for (const callback of this.callbacks) {
callback(this.internalState, this.publicState)
}
}
/**
* Checks if a given value is a valid hash value, otherwise throws an Error
* @param {any} value The value to check
*/
validateValue (value) {
if (typeof value !== 'string' || /[,/:]/.test(value)) {
throw new Error(`Invalid value "${value}"`)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment