├── .gitignore ├── rollup.config.js ├── package.json ├── src ├── Signal.js └── index.js ├── dist ├── pre-frame.min.js └── pre-frame.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | */package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from "rollup-plugin-terser"; 2 | 3 | export default { 4 | input: "./src/index.js", 5 | output: [{ 6 | file: "./dist/pre-frame.js", 7 | format: "cjs", 8 | name: "preframe", 9 | exports: "named" 10 | }, 11 | { 12 | file: "./dist/pre-frame.min.js", 13 | format: "iife", 14 | name: "preframe", 15 | exports: "named" 16 | } 17 | ], 18 | external: ['preact'], 19 | plugins: [ 20 | terser() 21 | ] 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pre-frame", 3 | "version": "1.0.0", 4 | "description": "re-frame inspired framework for js and preact", 5 | "main": "dist/pre-frame.js", 6 | "author": "\"The preframe authors\"", 7 | "license": "MIT", 8 | "private": false, 9 | "peerDependencies": { 10 | "preact": "^10.0.0" 11 | }, 12 | "devDependencies": { 13 | "rollup": "^2.32.1", 14 | "rollup-plugin-terser": "^7.0.2" 15 | }, 16 | "scripts": { 17 | "build": "rollup -c" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Signal.js: -------------------------------------------------------------------------------- 1 | function Signal(name, signals, extractor) { 2 | this.name = name; 3 | this._signals = typeof signals === 'function' 4 | ? ['appDb'] 5 | : signals; 6 | this._signalsNames = this._signals.reduce((acc, v, i) => {acc[v] = i; return acc} ,{}); 7 | this._extractor = typeof signals === 'function' ? signals : extractor; 8 | this.subscribers = new Set(); 9 | this.value; 10 | this._updateQueue = {}; 11 | this._triggerUpdate = true; 12 | this._processQueue = this._processQueue.bind(this); 13 | if (name === 'appDb') { 14 | this._signals = []; 15 | } 16 | } 17 | 18 | Signal.prototype.subscribe = function(caller) { 19 | if (this.subscribers.size === 0) { 20 | this.subscribers.add(caller); 21 | this._signals.forEach(name => this._updateQueue[name] = signalsRegistry[name].subscribe(this)); 22 | this._processQueue(true); 23 | } else { 24 | this.subscribers.add(caller); 25 | } 26 | return this.value; 27 | }; 28 | 29 | Signal.prototype.unsubscribe = function(caller) { 30 | this.subscribers.delete(caller); 31 | if (this.subscribers.size === 0) { 32 | this._signals.forEach(name => signalsRegistry[name].unsubscribe(this)) 33 | } 34 | }; 35 | 36 | Signal.prototype._processQueue = function(withoutUpdate) { 37 | this._triggerUpdate = true; 38 | const signalsValues = []; 39 | Object.keys(this._updateQueue).forEach(name => { 40 | const newValue = this._updateQueue[name]; 41 | signalsValues[this._signalsNames[name]] = newValue; 42 | }); 43 | const result = this._extractor.apply(null, signalsValues); 44 | if (result === this.value) { 45 | return; 46 | } 47 | this.value = result; 48 | if (!withoutUpdate) { 49 | this.subscribers.forEach(sub => { 50 | if ('update' in sub) { 51 | sub.update(this.name, result); 52 | } else { 53 | sub.setState({}); 54 | } 55 | }); 56 | } 57 | }; 58 | 59 | Signal.prototype.update = function(name, newValue, sync) { 60 | this._updateQueue[name] = newValue; 61 | if (this._triggerUpdate) { 62 | this._triggerUpdate = false; 63 | queueMicrotask(this._processQueue); 64 | } 65 | }; 66 | 67 | export const signalsRegistry = {}; 68 | 69 | export { Signal }; 70 | -------------------------------------------------------------------------------- /dist/pre-frame.min.js: -------------------------------------------------------------------------------- 1 | var preframe=function(e,t){"use strict";function s(e,t,s){this.name=e,this._signals="function"==typeof t?["appDb"]:t,this._signalsNames=this._signals.reduce(((e,t,s)=>(e[t]=s,e)),{}),this._extractor="function"==typeof t?t:s,this.subscribers=new Set,this.value,this._updateQueue={},this._triggerUpdate=!0,this._processQueue=this._processQueue.bind(this),"appDb"===e&&(this._signals=[])}s.prototype.subscribe=function(e){return 0===this.subscribers.size?(this.subscribers.add(e),this._signals.forEach((e=>this._updateQueue[e]=i[e].subscribe(this))),this._processQueue(!0)):this.subscribers.add(e),this.value},s.prototype.unsubscribe=function(e){this.subscribers.delete(e),0===this.subscribers.size&&this._signals.forEach((e=>i[e].unsubscribe(this)))},s.prototype._processQueue=function(e){this._triggerUpdate=!0;const t=[];Object.keys(this._updateQueue).forEach((e=>{const s=this._updateQueue[e];t[this._signalsNames[e]]=s}));const s=this._extractor.apply(null,t);s!==this.value&&(this.value=s,e||this.subscribers.forEach((e=>{"update"in e?e.update(this.name,s):e.setState({})})))},s.prototype.update=function(e,t,s){this._updateQueue[e]=t,this._triggerUpdate&&(this._triggerUpdate=!1,queueMicrotask(this._processQueue))};const i={},r=t.options.__r;let n;t.options.__r=e=>{n=e,r&&r(e)};const u={_:{}},c={},a={},o={dispatch:e=>d(...e),db:e=>{u._=e}},p={},h=(e,t)=>c[e]=t;i.appDb=new s("appDb",(e=>e));const b=function(e,t,r){if(1===arguments.length){const s=e.split(".");t=s.length>0?e=>s.reduce(((e,t)=>e[t]),e):t=>t[e]}i[e]=new s(e,t,r)},f=(e,t)=>{i.appDb.update("appDb",e,t)},_=[{after:(e,t)=>{Object.keys(e.effects).forEach((t=>{o[t](e.effects[t])}))}}],d=(e,t)=>{e in c?queueMicrotask((()=>{var s=c[e](t)(u._);u._=s,f(u._)})):e in a&&queueMicrotask((()=>{let s,i=a[e][0],r=a[e][1],n=_.reduce(((e,t)=>"before"in t?t.before(e):e),{event:[e].concat(t),db:u._});Array.isArray(i)?(n=i.reduce(((e,t)=>"before"in t?t.before(e):e),n),s={coeffects:n,effects:r(n,t)},s=i.reduceRight(((e,t)=>"after"in t?t.after(e):e),s)):s={coeffects:n,effects:r(n,t)},_.reduceRight(((e,t)=>"after"in t?t.after(e):e),s),f(u._)}))},l=e=>{const t="__c"in n?n.__c:n._component,s=i[e];if(!s.subscribers.has(t)){t.componentWillUnmount=function(){t.unsubscribe(sub)};return s.subscribe(t)}return s.value},g=(e=u._,t="")=>(Object.keys(e).forEach((s=>{const r=t+s;r in i||b(t+s),r in c||r in a||h(r,(e=>t=>({...t,[r]:e})));const n=e[s];"object"==typeof n&&"Object"===n.constructor.name&&g(n,t+s+".")})),e),y={},v={};return e.$dispatch=e=>e in v?v[e]:v[e]=()=>d(e),e.autoWire=g,e.bind=e=>({onInput:y[e]||(y[e]=t=>d(e,t.target.value)),value:l(e)}),e.dispatch=d,e.dispatchSync=(e,t)=>{if(!(e in c))throw"cannot find registered event";var s=c[e](t)(u._);u._=s,f(u._,!0)},e.injectCofx=e=>({before:p[e]}),e.regCofx=(e,t)=>p[e]=t,e.regEvent=h,e.regEventFx=function(e){let t,s;arguments.length>2?(t=arguments[1],s=arguments[2]):s=arguments[1],a[e]=[t,s]},e.regFx=(e,t)=>o[e]=t,e.regSub=b,e.signalsRegistry=i,e.subscribe=l,Object.defineProperty(e,"__esModule",{value:!0}),e}({},preact); 2 | -------------------------------------------------------------------------------- /dist/pre-frame.js: -------------------------------------------------------------------------------- 1 | "use strict";Object.defineProperty(exports,"__esModule",{value:!0});var e=require("preact");function t(e,t,s){this.name=e,this._signals="function"==typeof t?["appDb"]:t,this._signalsNames=this._signals.reduce(((e,t,s)=>(e[t]=s,e)),{}),this._extractor="function"==typeof t?t:s,this.subscribers=new Set,this.value,this._updateQueue={},this._triggerUpdate=!0,this._processQueue=this._processQueue.bind(this),"appDb"===e&&(this._signals=[])}t.prototype.subscribe=function(e){return 0===this.subscribers.size?(this.subscribers.add(e),this._signals.forEach((e=>this._updateQueue[e]=s[e].subscribe(this))),this._processQueue(!0)):this.subscribers.add(e),this.value},t.prototype.unsubscribe=function(e){this.subscribers.delete(e),0===this.subscribers.size&&this._signals.forEach((e=>s[e].unsubscribe(this)))},t.prototype._processQueue=function(e){this._triggerUpdate=!0;const t=[];Object.keys(this._updateQueue).forEach((e=>{const s=this._updateQueue[e];t[this._signalsNames[e]]=s}));const s=this._extractor.apply(null,t);s!==this.value&&(this.value=s,e||this.subscribers.forEach((e=>{"update"in e?e.update(this.name,s):e.setState({})})))},t.prototype.update=function(e,t,s){this._updateQueue[e]=t,this._triggerUpdate&&(this._triggerUpdate=!1,queueMicrotask(this._processQueue))};const s={},i=e.options.__r;let r;e.options.__r=e=>{r=e,i&&i(e)};const n={_:{}},u={},o={},c={dispatch:e=>_(...e),db:e=>{n._=e}},a={},p=(e,t)=>u[e]=t;s.appDb=new t("appDb",(e=>e));const h=function(e,i,r){if(1===arguments.length){const t=e.split(".");i=t.length>0?e=>t.reduce(((e,t)=>e[t]),e):t=>t[e]}s[e]=new t(e,i,r)},b=(e,t)=>{s.appDb.update("appDb",e,t)},f=[{after:(e,t)=>{Object.keys(e.effects).forEach((t=>{c[t](e.effects[t])}))}}],_=(e,t)=>{e in u?queueMicrotask((()=>{var s=u[e](t)(n._);n._=s,b(n._)})):e in o&&queueMicrotask((()=>{let s,i=o[e][0],r=o[e][1],u=f.reduce(((e,t)=>"before"in t?t.before(e):e),{event:[e].concat(t),db:n._});Array.isArray(i)?(u=i.reduce(((e,t)=>"before"in t?t.before(e):e),u),s={coeffects:u,effects:r(u,t)},s=i.reduceRight(((e,t)=>"after"in t?t.after(e):e),s)):s={coeffects:u,effects:r(u,t)},f.reduceRight(((e,t)=>"after"in t?t.after(e):e),s),b(n._)}))},d=e=>{const t="__c"in r?r.__c:r._component,i=s[e];if(!i.subscribers.has(t)){t.componentWillUnmount=function(){t.unsubscribe(sub)};return i.subscribe(t)}return i.value},l=(e=n._,t="")=>(Object.keys(e).forEach((i=>{const r=t+i;r in s||h(t+i),r in u||r in o||p(r,(e=>t=>({...t,[r]:e})));const n=e[i];"object"==typeof n&&"Object"===n.constructor.name&&l(n,t+i+".")})),e),g={},x={};exports.$dispatch=e=>e in x?x[e]:x[e]=()=>_(e),exports.autoWire=l,exports.bind=e=>({onInput:g[e]||(g[e]=t=>_(e,t.target.value)),value:d(e)}),exports.dispatch=_,exports.dispatchSync=(e,t)=>{if(!(e in u))throw"cannot find registered event";var s=u[e](t)(n._);n._=s,b(n._,!0)},exports.injectCofx=e=>({before:a[e]}),exports.regCofx=(e,t)=>a[e]=t,exports.regEvent=p,exports.regEventFx=function(e){let t,s;arguments.length>2?(t=arguments[1],s=arguments[2]):s=arguments[1],o[e]=[t,s]},exports.regFx=(e,t)=>c[e]=t,exports.regSub=h,exports.signalsRegistry=s,exports.subscribe=d; 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { options } from 'preact'; 2 | import { Signal, signalsRegistry } from './Signal'; 3 | 4 | const tmp = options.__r; 5 | 6 | let currentComponent; 7 | 8 | options.__r = vnode => { 9 | //if (typeof vnode.type === 'function') { 10 | currentComponent = vnode; 11 | //} 12 | if (tmp) { 13 | tmp(vnode); 14 | } 15 | }; 16 | 17 | let currentSubName; 18 | 19 | const db = { _: {}}; 20 | 21 | const events = {}; 22 | 23 | const eventsFx = {}; 24 | 25 | const effects = { 26 | dispatch: params => dispatch(...params), 27 | db: newDb => {db._ = newDb} 28 | }; 29 | 30 | const coeffects = {}; 31 | 32 | export const regEvent = (name, effect) => (events[name] = effect); 33 | 34 | export const regEventFx = function(name) { 35 | let interceptors; 36 | let effect; 37 | if (arguments.length > 2) { 38 | interceptors = arguments[1]; 39 | effect = arguments[2]; 40 | } else { 41 | effect = arguments[1]; 42 | } 43 | 44 | eventsFx[name] = [interceptors, effect]; 45 | } 46 | 47 | export const regFx = (name, handler) => effects[name] = handler; 48 | 49 | export const regCofx = (name, handler) => coeffects[name] = handler; 50 | // handler = coeffects => coeffects; 51 | 52 | export const injectCofx = name => ({ before: coeffects[name] }); 53 | 54 | const subs = {}; 55 | 56 | const masterSub = new Set(); 57 | 58 | signalsRegistry.appDb = new Signal('appDb', db => db); 59 | 60 | export const regSub = function(name, signals, extractor) { 61 | if (arguments.length === 1) { 62 | const path = name.split('.'); 63 | if (path.length > 0) { 64 | signals = db => path.reduce((acc, key) => acc[key], db); 65 | } else { 66 | signals = db => db[name]; 67 | } 68 | } 69 | signalsRegistry[name] = new Signal(name, signals, extractor); 70 | }; 71 | 72 | const notifySubscribers = (db, sync) => { 73 | signalsRegistry.appDb.update('appDb', db, sync); 74 | }; 75 | 76 | const deffaultInterceptors = [ 77 | { 78 | after: (ctx, ev) => { 79 | Object.keys(ctx.effects).forEach(key => { 80 | effects[key](ctx.effects[key]); 81 | }) 82 | } 83 | } 84 | ] 85 | 86 | export const dispatchSync = (name, params) => { 87 | if (name in events) { 88 | var newDb = events[name](params)(db._); 89 | db._ = newDb; 90 | notifySubscribers(db._, true); 91 | } else { 92 | throw "cannot find registered event"; 93 | } 94 | } 95 | 96 | export const dispatch = (name, params) => { 97 | if (name in events) { 98 | queueMicrotask(() => { 99 | var newDb = events[name](params)(db._); 100 | db._ = newDb; 101 | notifySubscribers(db._); 102 | }); 103 | } else if (name in eventsFx) { 104 | queueMicrotask(() => { 105 | let interceptors = eventsFx[name][0]; 106 | let effect = eventsFx[name][1]; 107 | 108 | let coeff = deffaultInterceptors.reduce((acc, interceptor) => 'before' in interceptor ? interceptor.before(acc) : acc, {event: [name].concat(params), db: db._ }) 109 | let ctx; 110 | if (Array.isArray(interceptors)) { 111 | coeff = interceptors.reduce((acc, interceptor) => 'before' in interceptor ? interceptor.before(acc) : acc, coeff); 112 | 113 | ctx = { coeffects: coeff, effects: effect(coeff, params) }; 114 | ctx = interceptors.reduceRight((acc, interceptor) => 'after' in interceptor ? interceptor.after(acc) : acc, ctx); 115 | } else { 116 | ctx = { coeffects: coeff, effects: effect(coeff, params) }; 117 | } 118 | deffaultInterceptors.reduceRight((acc, interceptor) => 'after' in interceptor ? interceptor.after(acc) : acc , ctx) 119 | notifySubscribers(db._); 120 | }); 121 | } 122 | }; 123 | 124 | export const subscribe = name => { 125 | const comp = '__c' in currentComponent ? currentComponent.__c : currentComponent._component; 126 | const signal = signalsRegistry[name]; 127 | if (!signal.subscribers.has(comp)) { 128 | comp.componentWillUnmount = function() { 129 | comp.unsubscribe(sub); 130 | }; 131 | const res = signal.subscribe(comp); 132 | return res; 133 | } 134 | return signal.value; 135 | }; 136 | 137 | export const autoWire = (obj = db._, prefix = '') => { 138 | Object.keys(obj).forEach(key => { 139 | const name = prefix + key; 140 | if (!(name in signalsRegistry)) { 141 | regSub(prefix + key); 142 | } 143 | if (!(name in events) && !(name in eventsFx)) { 144 | regEvent(name, prop => db => ({ ...db, [name]: prop })); 145 | } 146 | const child = obj[key]; 147 | if (typeof child === 'object' && child.constructor.name === 'Object') { 148 | autoWire(child, prefix + key + '.'); 149 | } 150 | }) 151 | return obj; 152 | }; 153 | 154 | const cachedBinds = {}; 155 | 156 | export const bind = name => ({ 157 | onInput: cachedBinds[name] || (cachedBinds[name] = e => dispatch(name, e.target.value)), 158 | value: subscribe(name) 159 | }); 160 | 161 | const cachedDispatches = {}; 162 | 163 | export const $dispatch = name => { 164 | if (name in cachedDispatches) { 165 | return cachedDispatches[name]; 166 | } else { 167 | return (cachedDispatches[name] = () => dispatch(name)); 168 | } 169 | } 170 | 171 | export { signalsRegistry }; 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔄 Pre-Frame 2 | Truly reactive framework inspired by [re-frame](https://github.com/day8/re-frame). 3 | 1.5 kB (gzipped) 4 | 5 | There is one way data flow, similar to [redux](https://redux.js.org/). But without tons of boilerplate reducers, 6 | adapters, mappings to props and dispatch, context providers etc. 7 | And on top of this, you have more predictable state changes, due to ability to describe 8 | effectfull event handlers by pure functions and chain that event handlers. 9 | You only care of pure state transformations and dealing with 10 | external state of world is handled by pre-frame for you via concept of coeffects. 11 | 12 | **Easier testing, less code, less bugs. 1.5 kB** 13 | 14 | ### It works like this 15 | 1. describe your data store aka db in re-frame terminology 16 | 2. describe events, there are two types 17 | a) regEvent - pure transformations of state 18 | b) regEventFx - can calculate new state based on some other state from outer world 19 | for example actual date, results of some ajax queries, etc. and can trigger other 20 | event handlers 21 | 3. describe signal graph, which means describe what happens when app state changes 22 | 4. describe view - simply preact components, and bind it with app state through 23 | subscriptions. 24 | 25 | For more detailed description read [re-frame documentation](http://day8.github.io/re-frame/re-frame/). 26 | ## Install 27 | ```sh 28 | yarn add PanJarda/pre-frame 29 | ``` 30 | or 31 | ```html 32 | 33 | 34 | 35 | ``` 36 | ## Examples 37 | ### Simple counter 38 | ```js 39 | import { h, render } from 'preact'; 40 | 41 | import { 42 | subscribe as $, 43 | regEvent, 44 | dispatchSync, 45 | $dispatch, 46 | bind, 47 | autoWire, 48 | } from 'pre-frame'; 49 | 50 | // autoWire creates subscriptions for all keyes in app state 51 | // autoWire is not pure, since it automatically registers subscriptions and eventhandlers 52 | // but it is there just for convenience 53 | regEvent('init-store', 54 | params => state => autoWire({ 55 | counter: 0 56 | })); 57 | /* without autoWire it would be equivalent to write: 58 | regEvent('init-store', 59 | params => state => ({ counter: 0 })) 60 | 61 | regEvent('counter', 62 | params => state => ({...state, counter: params })); 63 | 64 | regSub('counter') // which is sugar for regSub('counter', state => state.counter) 65 | */ 66 | 67 | // all events are pure functions 68 | regEvent('inc', 69 | params => state => 70 | ({ ...state, counter: parseInt(state.counter) + 1 })); 71 | 72 | regEvent('dec', 73 | params => state => 74 | ({ ...state, counter: state.counter - 1 })); 75 | 76 | // $dispatch is same as () => dispatch(..), but 77 | // callback is cached so there is no recreation of callback 78 | // on each render (similar to useCallback hook) 79 | const App = () => (
80 |

{ $('counter') }

81 | 82 | 83 |
84 | 85 | 86 |
); 87 | 88 | // dispatch is asynchronous but we need to initialize store 89 | // before render, so we use synchronous version 90 | dispatchSync('init-store'); 91 | 92 | render(, document.getElementById('app')) 93 | ``` 94 | ### Signal graph 95 | ```js 96 | import { h, render } from 'preact'; 97 | 98 | import { 99 | subscribe as $, 100 | regSub, 101 | regEvent, 102 | $dispatch, 103 | dispatchSync, 104 | autoWire, 105 | bind 106 | } from 'pre-frame'; 107 | 108 | regEvent('init-store', 109 | params => state => autoWire({ 110 | color: 'green', 111 | firstName: 'John', 112 | lastName: 'Doe' 113 | })); 114 | 115 | // declare signal graph 116 | // every time firstName or lastName changes 117 | // fullName recalculates value and notify preact component to rerender 118 | regSub('fullName', 119 | ['firstName', 'lastName'], 120 | (firstName, lastName) => firstName + ' ' + lastName) 121 | 122 | const App = () => 123 |
124 |

Mr. { $('fullName') }

125 |

Favourite color is: { $('color') }

126 | 127 | 128 | 129 |
; 130 | // 131 | 132 | dispatchSync('init-store'); 133 | 134 | render(, document.getElementById('app')) 135 | ``` 136 | ### Coeffects and interceptors 137 | ```js 138 | 139 | // register interceptor 140 | regCofx('now', 141 | coeff => ({ ...coeff, now: (new Date()).toLocaleTimeString()})); 142 | 143 | regEventFx('now', 144 | [ injectCofx('now') ], // interceptor injects current time to coeffect 145 | coeff => ({ db: { ...coeff.db, now: coeff.now }); 146 | ``` 147 | 148 | ### Ajax using effects 149 | ```js 150 | // effect handler 151 | regFx('ajax', params => { 152 | const p = { method: 'method' in params ? params.method : 'GET' }; 153 | fetch(params.uri, p) 154 | .then(response => params.format === 'json' ? response.json() : response.text()) 155 | .then(data => dispatch(params.onSuccess, data)) 156 | .catch(err => dispatch(...params.onFailure)) 157 | }); 158 | 159 | // register effectfull event 160 | regEventFx('get-articles', // use: dispatch('get-articles') 161 | coeff => ({ 162 | db: {...coeff.db, loading: true }, 163 | ajax: { 164 | uri: '/api/articles' 165 | format: 'json', 166 | method: 'GET', 167 | onSuccess: 'get-articles-success', 168 | onFailure: ['api-request-error', 'get-articles'] 169 | } 170 | })) 171 | 172 | regEvent('get-articles-success', 173 | articles => db => 174 | ({ ...db, articles, loading: false })) 175 | 176 | regEvent('api-request-error', 177 | err => db => 178 | ({ ...db, loading: false, error: 'failed to retrieve ' + err })) 179 | ``` 180 | --------------------------------------------------------------------------------