diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/README.md b/README.md new file mode 100644 index 0000000..8add7bd --- /dev/null +++ b/README.md @@ -0,0 +1,149 @@ +<div align="center"> + +# Reherit +Reactive state management with prototypal inheritance + +</div> + +## About +Reherit is a reactive state manager intended to be used with interactive +interfaces made up of many seperate components. Each components state (called +store) is inherited from it's parent using prototypal inheritance. + +Stores can be accessed and manipulated by any component in the tree. Whenever a +store is changed, an update is issued and subscribers are notified. + +### Features +- **small API:** you'll get by only learning three functions +- **it's tiny:** just over 1kb, you'll barely know it's there +- **no tooling:** no compilation required, works out of the box +- **runs anywhere:** anywhere ES modules are supported, that is + +## Usage + +```javascript +import { use, store, watch } from 'reherit' + +var dog = use(Dog, { name: 'Maja' }).resolve(console.log) + +console.log(dog) + +dog.pet() + +function Dog () { + var [name] = store('name') + var [mood, setMood] = store('mood', 'sad') + + watch('mood', function (mood) { + console.log(`${name} is ${mood}`) + }) + + return { name, mood, pet: () => setMood('happy') } +} +``` + +Running the above example will print the following to the console: + +```bash +-> Maja is sad +-> { name: 'Maja', mood: 'sad' } +-> Maja is happy +-> { name: 'Maja', mood: 'happy' } +``` + +### Installation +Reherit is distributed as a ES module and can be installed with your favourite package manager, e.g.: + +```bash +npm install reherit +``` + +It can also be imported from unpkg.com + +```javascript +import { use, store, watch } from 'https://unpkg.com/reherit/dist/index.js' +``` + +## API +While resolving a component tree, Reherit builds a "stack" of "layers" which +reflects the inheritance of stores, one layer inherits from the one before it, +and so on. However, there are only three functions you really need to learn to +use Reherit. + +### `use(Function, [store, [...args]])` +Creates a [`Layer`](#layer) which can be [`resolved`](#layerresolvefunction) +into the rendered component. Takes a component function as it's first argument +followed by an optional store and arguments. The store (if provided) will be +assigned onto the inherited store. Arguments are forwarded to the component +function. Returns [`Layer`](#layer). + +### `store([key, [initialValue]])` +Read from component store, optionally providing an initial value if none is set +already. Returns a touple (fancy word for array with two values) with the value +and a function for updating the value. + +If the key being requested is not set to the current component store, it will +walk up the prototype chain looking for it. If found on a parent store, that +value will be returned and the update function will update that parent value. + +If the key is not found in the prototype chain, but an initial value is +provided, the initial value will be set to the current component store and the +update function will only update the current component. + +If no key is provided, the store object will be returned and the update function +will assign properties directly to the store. + +```javascript +var [state, setState] = store() +var [value, setValue] = store('key', 'hello') + +setState({ key: 'hi' }) // <- These two have the same effect +setValue('hi') // <- +``` + +### `watch(key, [Function])` +Listen for changes to the store. If key is a function the function will be +attached as a listener for any changes to the store. Key can also be an array of +keys to watch. Listeners are called *after* the component has rendered and on +every update to the given key(s). + +The listener may return a function which will be called on the next update of +the given key(s) *before* the component is rendered. + +```javascript +watch('key', function (value) { + console.log('value is', value) + return function (value) { + console.log('value has been updated to', value) + } +}) +``` + +### Layer +A layer is the container for a component, holding its store, listeners and +children. + +#### `Layer#resolve(Function)` +Call component function, calling any listeners and handling state in the +process. Generators and promises returned/yielded from the component function +are awaited for and resolved. + +If provided, the function passed to resolve will be called on subsequent +asynchronous updates. + +#### `Layer#assign(store, args)` +Update layer internal store and cached arguments without issuing an update. + +#### `Layer#update(key, value)` +Update the store with given key/value pair. If no value is provided, the key is +assigned onto the store. + +#### `Layer#subscribe(key, Function)` +Subscribe to updates made to the layer store. + +#### `Layer#emit(key, value)` +Call all subsribers registered for the given key. The value will be passed to +the listeners. + +## License +MIT diff --git a/index.js b/index.js new file mode 100644 index 0000000..3c8adf3 --- /dev/null +++ b/index.js @@ -0,0 +1,334 @@ +const ROOT = Symbol('root') +const UPDATE = Symbol('update') +const CANCEL = Symbol('cancel') +const INTERRUPT = Symbol('interrupt') + +/** Pool of used children per parent */ +const pool = new WeakMap() + +/** Use with {@link Layer#subscribe} to listen for any store changes */ +export const ANY = Symbol('any') + +/** Current stack of layers being resolved */ +export const stack = [] + +/** + * Read from store. Returns value + * @param {any} [key] Store key name, omitting key yields store object + * @param {any} [initial] Initial value if not found in store + * @returns {Array} A touple of current value and an update function + */ +export function store (key, initial) { + console.assert(stack.length, 'store used outside render cycle') + + var layer = stack[0] + if (!key) return [layer.store, (next) => layer.update(next)] + if (typeof key === 'string' && key[0] === '$') { + const _key = key.substring(1) + let value + if (hasOwnProperty(layer.store[_key])) { + value = layer.store[_key] + } else if (typeof initial !== 'undefined') { + value = layer.store[_key] = initial + layer.changes.add(_key) + } + return [value, (next) => layer.update(key, next)] + } + var value = layer.store[key] + if (typeof value === 'undefined' && typeof initial !== 'undefined') { + value = layer.store[key] = initial + layer.changes.add(key) + } + return [value, (next) => layer.update(key, next)] +} + +/** + * Watch store for changes + * @param {any} key store key to watch or function to call on any change + * @param {Function} [fn] Function ot call when value of key change + * @returns {void} + */ +export function watch (key, fn) { + console.assert(stack.length, 'watch used outside render cycle') + + if (Array.isArray(key)) { + return key.forEach((_key) => watch(_key, function () { + var layer = stack[0] + return fn(key.map((__key) => layer.store[__key])) + })) + } else if (typeof key === 'function') { + fn = key + key = ANY + } + if (key === ANY) { + stack.forEach((layer) => layer.subscribe(key, fn)) + } else { + const layer = stack.find((layer) => hasOwnProperty(layer.store, key)) + if (layer) { + layer.subscribe(key, fn) + if (layer.fresh) layer.changes.add(key) + } + } +} + +/** + * Creates a layer for a component + * @param {Function} fn Component render Function + * @param {Object} store Store to use for component + * @param {...any} args Arguments to forward to component + * @returns {Layer} + */ +export function use (fn, store, ...args) { + var layer = stack[0] + if (store == null) store = {} + if (!layer) return new Layer(ROOT, fn, store, args) + if (!layer.children.has(fn)) layer.children.set(fn, new Set()) + if (!pool.get(layer).has(fn)) pool.get(layer).set(fn, new Set()) + + var child + var children = layer.children.get(fn) + var candidates = pool.get(layer).get(fn) + var key = typeof store.key === 'undefined' ? children.size + 1 : store.key + + for (const candidate of candidates) { + if (candidate.key === key) { + child = candidate + child.assign(store, args) + break + } + } + + if (!child) { + store = Object.assign(Object.create(layer.store), store) + child = new Layer(key, fn, store, args) + } + + children.add(child) + return child +} + +export class Layer { + /** + * Create a layer + * @param {any} key Unique identifier for component + * @param {Function} fn Component render Function + * @param {Object} store Store to use for Component + * @param {Array} args Arguments to forward to component + */ + constructor (key, fn, store, args) { + this.key = key + this.args = args + this.store = store + this.render = render + this.fresh = true + this.stack = [...stack] + this.changes = new Set() + this.children = new Map() + this.listeners = new Map() + pool.set(this, new WeakMap()) + + var queued = false + var running = false + + /** + * Call component render function, recursively rerunning on every update + * @returns {any} + */ + function render () { + if (running) { + queued = true + } else { + try { + queued = false + running = true + var res = unwind(fn(...this.args)) + } catch (err) { + if (err === INTERRUPT) { + queued = true + } else if (err === CANCEL) { + var cancel = true + } else { + throw err + } + } finally { + running = false + } + + if (cancel) return + if (queued) res = this.render() + return res + } + } + } + + /** + * Make updates to layer internals on reuse + * @param {Object} store Properties with which to extend store + * @param {Array} args Arguments to forward to component + * @returns {void} + */ + assign (store, args) { + this.args = args + Object.assign(this.store, store) + } + + /** + * Resolve component + * @param {Function} callback Function to call on async updates + * @returns {any} + */ + resolve (callback) { + if (!stack.includes(this)) { + stack.unshift(this) + } + + if (typeof callback === 'function') { + this.subscribe(UPDATE, function onupdate (res) { + callback(res) + return onupdate + }) + } + + try { + for (const key of this.changes) { + this.emit(key, this.store[key]) + } + + const res = this.render() + + for (const key of this.changes) { + this.emit(key, this.store[key]) + } + + return res + } finally { + const pooled = pool.get(this) + for (const [fn, children] of this.children) { + pooled.set(fn, new Set(children)) + } + this.fresh = false + this.children.clear() + this.changes.clear() + stack.shift() + } + } + + /** + * Update store, issuing an async render + * @param {any} key Key of the value to update or a new store to assign + * @param {any} [value] New value to assign for key + * @returns {void} + */ + update (key, value) { + if (typeof value === 'undefined') { + Object.assign(this.store, key) + key = ANY + } else if (typeof key === 'string' && key[0] === '$') { + key = key.substring(1) + this.store[key] = value + } else { + if (hasOwnProperty(this.store, key)) { + this.store[key] = value + } else { + const parent = this.stack.find(function (parent) { + return hasOwnProperty(parent.store, key) + }) + if (!parent) { + this.store[key] = value + } else { + parent.changes.add(key) + parent.emit(UPDATE, parent.resolve(), { any: false }) + if (stack.includes(this)) throw CANCEL + return + } + } + } + + this.changes.add(key) + if (stack.includes(this)) throw INTERRUPT + this.emit(UPDATE, this.resolve(), { any: false }) + } + + /** + * Subscribe to changes made to store. The listener function may return + * a callback function which will be called on next change, before render. + * @param {any} key Key to subscribe to + * @param {Function} fn Function to call on change + * @returns {void} + */ + subscribe (key, fn) { + if (!this.listeners.has(fn)) { + this.listeners.set(fn, new Set()) + } + this.listeners.get(fn).add(key) + } + + /** + * Emit change + * @param {any} key Emit an event to subscribed listeners + * @param {any} value New value for subscribed key + * @param {Object} [opts] Configure behavior + * @param {boolean} [opts.any=true] Trigger listerns for {@link ANY} + */ + emit (key, value, opts = {}) { + var { any = true } = opts + var listeners = [] + for (const [fn, keys] of this.listeners.entries()) { + if (!keys.has(key) && (!any || !keys.has(ANY))) continue + const cleanup = keys.has(key) && key !== ANY ? fn(value) : fn() + this.listeners.delete(fn) + if (typeof cleanup === 'function') listeners.push(cleanup) + } + + for (const listener of listeners) { + this.subscribe(key, listener) + } + } +} + +/** + * Resolve nested generator and promises + * @param {any} obj + * @param {any} [value] + * @returns {any} + */ +function unwind (obj, value) { + if (isGenerator(obj)) { + const res = obj.next(value) + if (res.done) return res.value + if (isPromise(res.value)) { + return res.value.then(unwind).then((val) => unwind(obj, val)) + } + return unwind(obj, res.value) + } else if (isPromise(obj)) { + return obj.then(unwind) + } + return obj +} + +/** + * Determin if object is promise + * @param {any} obj + * @returns {boolean} + */ +function isPromise (obj) { + return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function' +} + +/** + * Determine if object is generator + * @param {any} obj + * @return {boolean} + */ +function isGenerator (obj) { + return obj && typeof obj.next === 'function' && typeof obj.throw === 'function' +} + +/** + * Check if object has key set on self + * @param {Object} obj The object to check + * @param {any} key The key to look for + */ +function hasOwnProperty (obj, key) { + return Object.prototype.hasOwnProperty.call(obj, key) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cedfaa9 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "reherit", + "version": "1.0.0", + "description": "Reactive state management with prototypal inheritance", + "main": "index.js", + "type": "module", + "files": [ + "index.js" + ], + "scripts": { + "test": "node test.js && standard", + "build": "rollup -p rollup-plugin-strip -p rollup-plugin-terser -m -o dist/index.js index.js", + "postbuild": "cat dist/index.js | gzip --best | wc -c | pretty-bytes", + "prepublishOnly": "npm run build" + }, + "author": "Carl Törnqvist <carl@tornqv.ist>", + "repository": "github:tornqvist/reherit", + "license": "MIT", + "devDependencies": { + "pretty-bytes-cli": "^2.0.0", + "rollup": "^2.23.1", + "rollup-plugin-strip": "^1.2.2", + "rollup-plugin-terser": "^7.0.0", + "standard": "^14.3.4", + "tape": "^5.0.1" + } +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..7cbbdb6 --- /dev/null +++ b/test.js @@ -0,0 +1,248 @@ +import { createRequire } from 'module' +import { use, store, watch, Layer, ANY, stack } from './index.js' + +const require = createRequire(import.meta.url) +const test = require('tape') + +test('API', function (t) { + t.plan(16) + + t.equal(typeof use, 'function', 'exports use function') + t.equal(typeof store, 'function', 'exports store function') + t.equal(typeof watch, 'function', 'exports watch function') + t.equal(typeof Layer, 'function', 'exports Layer class') + t.equal(typeof ANY, 'symbol', 'exports ANY symbol') + t.ok(Array.isArray(stack), 'exports layer stack') + + var value = 1 + var layer = use(function (arg) { + t.equal(arg, value, 'value was forwarded') + if (value === 1) { + t.equal(stack.length, 1, 'stack is populated during resolve') + t.equal(stack[0], layer, 'stack has layer currently being resolved') + } else if (value === 2) { + t.equal(layer.store.one, 1, 'store was assigned') + t.equal(layer.store.two, 2, 'store was updated') + } else { + t.fail() + } + return arg + }, null, value) + + t.ok(layer instanceof Layer, 'exposes layer') + + var res = layer.resolve() + t.equal(res, value, 'returns result') + + layer.assign({ one: 1 }, [++value]) + layer.update('two', 2) + layer.subscribe('three', function (value) { + t.equal(value, 3, 'value forwarded to keyed listener') + }) + layer.subscribe(ANY, function (value) { + t.equal(typeof value, 'undefined', 'value not forwarded to unkeyed listener') + }) + layer.emit('three', 3) + layer.emit('four', 4, { any: false }) +}) + +test('async components', async function (t) { + t.plan(1) + + var res = await Promise.all([ + use(async () => 1).resolve(), + use(function * () { + var res = yield 2 + return res + }).resolve(), + use(function * () { + var res = yield Promise.resolve(3) + return res + }).resolve() + ]) + + t.deepEqual(res, [1, 2, 3], 'all async components resolved') +}) + +test('store default value', function (t) { + t.plan(1) + var layer = use(Main) + layer.resolve() + + function Main () { + var [value] = store('value', 'fallback') + t.equal(value, 'fallback', 'uses fallback value') + } +}) + +test('providing a store', function (t) { + t.plan(1) + var layer = use(Main, { value: 'hi' }) + layer.resolve() + + function Main () { + var [value] = store('value') + t.equal(value, 'hi', 'provided store was used') + } +}) + +test('manipulating stores', function (t) { + t.plan(3) + + var step = 0 + var steps = [1, 2, 3, 4] + var layer = use(Main) + var res = layer.resolve() + + t.equal(res, steps[2], 'layer is resolved synchronously') + + function Main () { + var [value, setValue] = store('value', steps[0]) + var [, setState] = store() + if (step === 0) { + setState({ value: steps[++step] }) + t.fail('should interrupt on update mid-render') + } else if (step === 1) { + t.equal(value, steps[1], 'can set state') + setValue(steps[++step]) + t.fail('should interrupt on update mid-render') + } else if (step === 2) { + t.equal(value, steps[2], 'can set property by key') + } + return value + } +}) + +test('top level store is mutated', function (t) { + t.plan(1) + var state = {} + + use(Main, state).resolve() + t.equal(state.value, 'hi', 'state was mutated') + + function Main () { + store('value', 'hi') + } +}) + +test('callback', function (t) { + t.plan(1) + + var layer = use(Main) + + layer.resolve(function (res) { + t.equal(res, 2, 'resolve callback called on async update') + }) + + function Main () { + var [value, setValue] = store('value', 1) + if (value === 1) setTimeout(() => setValue(2), 100) + return value + } +}) + +test('watching store', function (t) { + t.plan(9) + var expected = 0 + var layer = use(Main) + layer.resolve() + + function Main () { + var [value, setValue] = store('value', 0) + watch('value', function (_value) { + t.equal(_value, expected, `keyed watcher called with new value after ${value ? 'update' : 'render'}`) + return function () { + t.pass('keyed cleanup called before update') + } + }) + watch(['value'], function (values) { + t.deepEqual(values, [expected], `array of keys watcher called with new values after ${value ? 'update' : 'render'}`) + return function () { + t.pass('array of keys cleanup called before update') + } + }) + watch(function () { + t.equal(arguments.length, 0, `no value passed to unkeyed watcher after ${value ? 'update' : 'render'}`) + return function () { + t.pass('unkeyed cleanup called before update') + } + }) + if (value === 0) { + setTimeout(function () { + setValue(++expected) + }, 100) + } + } +}) + +test('children', function (t) { + t.plan(8) + var children = Array(3).fill().map((_, i) => i) + var layer = use(Main, { root: 0 }) + var tree = layer.resolve(function (tree) { + t.deepEqual(tree, { + root: 1, + children: [{ + key: 2, + index: 0, + local: 2 + }, { + key: 1, + index: 1, + local: 1 + }, { + key: 0, + index: 2, + local: 0 + }] + }, 'reversed tree maintained stores') + }) + + t.deepEqual(tree, { + root: 0, + children: [{ + key: 0, + index: 0, + local: 0 + }, { + key: 1, + index: 1, + local: 1 + }, { + key: 2, + index: 2, + local: 2 + }] + }, 'tree match') + + function Main () { + var [root, setRoot] = store('root') + + if (root === 0) { + setTimeout(function () { + children.reverse() + setRoot(1) + }, 100) + } + + return { + root, + children: children.map(function (key, i) { + const layer = use(child, { key }, i) + return layer.resolve(function () { + t.fail('childrens callback should not be called') + }) + }) + } + } + + function child (index) { + var [key] = store('key') + var [local] = store('local', index) + watch('root', function (value) { + if (!value) t.pass('child watcher for parent store called after render') + else t.pass('child watcher for parent store called after update') + }) + return { index, key, local } + } +})