Last active
April 16, 2020 15:13
-
-
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
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
// 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) | |
}) | |
} | |
} |
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
/* 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