Skip to content

Instantly share code, notes, and snippets.

@myobie
Last active April 16, 2020 15:13
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 myobie/25c302dece9564b696c9ab1bf19d84f0 to your computer and use it in GitHub Desktop.
Save myobie/25c302dece9564b696c9ab1bf19d84f0 to your computer and use it in GitHub Desktop.
A not very sophisticated "store" where I can dispatch "actions" if a very typesafe way
// Fake immer
/** @template T */
export class State {
/** @typedef {function(T):void} Callback */
/** @typedef {function(): T} InitialValueFactory */
/**
* @param {T | InitialValueFactory} initialValue
*/
constructor (initialValue) {
if (initialValue instanceof Function) {
/** @type {T} */
this.value = initialValue()
} else {
/** @type {T} */
this.value = initialValue
}
deepFreeze(this.value)
/** @type Callback[] */
this._callbacks = []
}
/**
* @param {T | function(T): T} newValue
*/
update (newValue) {
const originalValue = this.value
const mutableValue = deepCopy(this.value)
if (newValue instanceof Function) {
this.value = newValue(mutableValue)
} else {
this.value = newValue
}
deepFreeze(this.value)
if (originalValue !== this.value) {
this._callbacks.forEach(cb => {
cb(this.value)
})
}
}
/**
* @param {Callback} cb
* @returns {void}
*/
onUpdate (cb) {
this._callbacks.push(cb)
}
}
/**
* @param {any} po - possible object
* @returns {boolean}
*/
function isObject (po) {
return (
po !== null &&
typeof po === 'object' &&
po.constructor === Object &&
Object.prototype.toString.call(po) === '[object Object]'
)
}
/**
* @template O
* @param {O} o
* @param {number} [level=0]
* @returns {O}
*/
function deepCopy (o, level = 0) {
if (level > 20) {
return o
}
if (isObject(o)) {
o = Object.assign({}, o)
for (const key in o) {
o[key] = deepCopy(o[key], level + 1)
}
return o
} else if (Array.isArray(o)) {
// @ts-ignore
o = Array.from(o)
for (const i in o) {
o[i] = deepCopy(o[i], level + 1)
}
return o
} else {
return o
}
}
/**
* @param {any} o
* @param {number} [level=0]
* @returns {void}
*/
function deepFreeze (o, level = 0) {
if (level > 20) {
return
}
Object.freeze(o)
if (isObject(o)) {
Object.values(o).forEach(v => {
deepFreeze(v, level + 1)
})
}
}
/* global requestAnimationFrame */
import { State } from './state.js'
import { createMeasurement } from './measure.js'
/** @template T */
export class Storage {
/**
* @template A
* @callback SyncAction
* @param {A} arg
* @param {T} state
* @returns {T}
*/
/**
* @template A
* @callback AsyncAction
* @param {A} arg
* @param {T} state
* @returns {Promise<void>}
*/
/** @typedef {function(T, T):void} StateChangeCallback */
/** @typedef {function(T, any, string):void | Promise<void>} ActionCallback */
/** @typedef {function(T, Error, string):void} ErrorCallback */
/** @typedef {function(): T} InitialValueFactory */
/**
* @typedef {Object} HistoryEntry
* @property {string} name
* @property {any} arg
* @property {T} result
*/
/**
* @typedef {Object} Options
* @property {boolean} [logStateChanges=false]
* @property {boolean} [logActionCallbackTimings=false]
*/
/**
* @param {T | InitialValueFactory} initialState
* @param {Options} [options={}]
*/
constructor (initialState, options = {}) {
this._options = options || {}
this._state = new State(initialState)
this._previousStateValue = Object.assign({}, this._state.value)
/** @type {HistoryEntry[]} */
this._history = []
/** @type {StateChangeCallback[]} */
this._stateChangeCallbacks = []
/** @type {ActionCallback[]} */
this._actionCallbacks = []
/** @type {ErrorCallback[]} */
this._errorCallbacks = []
this._state.onUpdate(newState => {
if (this._options.logStateChanges) {
console.debug('✨ state changed', newState, this._previousStateValue)
}
this._stateChangeCallbacks.forEach(cb => {
cb(newState, this._previousStateValue)
})
})
}
get state () {
return this._state.value
}
/**
* @template A
* @param {AsyncAction<A>} action
* @param {A} arg
* @returns {Promise<void>}
*/
async next (action, arg) {
const asyncEnd = createMeasurement()
return nextTick(async () => {
try {
const currentState = this._state.value
await action(arg, currentState)
this._history.push({
name: action.name,
arg,
result: this._state.value
})
// TODO: does it make sense to wait for this here or should we accept a
// complete callback like send?
await Promise.all(
this._actionCallbacks.map(cb => {
cb(this._state.value, arg, action.name)
})
)
} catch (e) {
console.error(`🧨 ${action.name}`, e.message, e.stack)
this._errorCallbacks.forEach(cb => {
cb(this._state.value, e, action.name)
})
} finally {
const duration = asyncEnd()
console.debug(`🏁 ${duration.toFixed(3)}ms async ${action.name}`)
}
})
}
/**
* @template A
* @param {SyncAction<A>} action
* @param {A} arg
* @param {function(): void} [complete] - called after every onAction callback is completly finished
* @returns {T}
*/
send (action, arg, complete) {
const syncEnd = createMeasurement()
const asyncEnd = createMeasurement()
try {
this._state.update(oldState => {
// oldState is mutable inside this callback
const newState = action(arg, oldState)
this._previousStateValue = oldState
return newState
})
this._history.push({
name: action.name,
arg,
result: this._state.value
})
Promise.all(
this._actionCallbacks.map(cb => {
cb(this._state.value, arg, action.name)
})
)
.then(() => {
if (complete) {
complete()
}
})
.catch(e => {
console.error(`🧨 ${action.name} callbacks`, e.message, e.stack)
})
.finally(() => {
const asyncDuration = asyncEnd()
if (this._options.logActionCallbackTimings) {
console.debug(
`🏁 ${asyncDuration.toFixed(3)}ms ${
action.name
} callbacks finished`
)
}
})
return this._state.value
} catch (e) {
console.error(`🧨 ${action.name}`, e.message, e.stack)
this._errorCallbacks.forEach(cb => {
cb(this._state.value, e, action.name)
})
throw e
} finally {
const syncDuration = syncEnd()
console.debug(`🚀 ${syncDuration.toFixed(3)}ms ${action.name}`)
}
}
/**
* @param {ErrorCallback} cb
* @returns {void}
*/
onError (cb) {
this._errorCallbacks.push(cb)
}
/**
* @param {ActionCallback} cb
* @returns {void}
*/
onAction (cb) {
this._actionCallbacks.push(cb)
}
/**
* @param {StateChangeCallback} cb
* @returns {void}
*/
onStateChange (cb) {
this._stateChangeCallbacks.push(cb)
}
}
/**
* @template O
* @param {function(): Promise<O>} cb
* @returns {Promise<O>}
*/
function nextTick (cb) {
return new Promise((resolve, reject) => {
requestAnimationFrame(() => {
cb()
.then(value => resolve(value))
.catch(e => reject(e))
})
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment