4 |
5 | Sekoia.js TODO MVC
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/modules/utils/throttle.js:
--------------------------------------------------------------------------------
1 | export function throttle (callback, interval) {
2 | let pending = 0;
3 | const reset = () => (pending = 0);
4 | return arg => {
5 | if (!pending) {
6 | callback(arg);
7 | pending = setTimeout(reset, interval);
8 | }
9 | }
10 | }
--------------------------------------------------------------------------------
/src/modules/utils/hash-string.js:
--------------------------------------------------------------------------------
1 | export function hashString(str) {
2 | if (!str.length) return '0';
3 | let hash = 0;
4 | for (let i = 0, char; i < str.length; i++) {
5 | char = str.charCodeAt(i);
6 | hash = ((hash << 5) - hash) + char;
7 | hash = hash & hash;
8 | }
9 | return hash + '';
10 | }
--------------------------------------------------------------------------------
/src/modules/state/create-state.js:
--------------------------------------------------------------------------------
1 | import { ReactiveArray } from "./ReactiveArray.js";
2 | import { ReactiveObject } from "./ReactiveObject.js";
3 |
4 | export function createState(objectOrArray, options) {
5 | if (Array.isArray(objectOrArray)) {
6 | return new ReactiveArray(objectOrArray, options);
7 | } else {
8 | return new ReactiveObject(objectOrArray);
9 | }
10 | }
--------------------------------------------------------------------------------
/src/modules/component/internal/StateProvider.js:
--------------------------------------------------------------------------------
1 | export const StateProvider = {
2 | setState(item) {
3 | this.__cache.set(++this.__uid, item);
4 | return this.__uid;
5 | },
6 | popState(uid) {
7 | uid = Number(uid);
8 | if (this.__cache.has(uid)) {
9 | const state = this.__cache.get(uid);
10 | this.__cache.delete(uid);
11 | return state;
12 | }
13 | },
14 | __cache: new Map(),
15 | __uid: -1
16 | };
--------------------------------------------------------------------------------
/src/modules/utils/deep-clone.js:
--------------------------------------------------------------------------------
1 | export function deepClone(x) {
2 |
3 | if (!x || typeof x !== 'object') {
4 | return x;
5 | }
6 |
7 | if (Array.isArray(x)) {
8 |
9 | const y = [];
10 |
11 | for (let i = 0; i < x.length; i++) {
12 | y.push(deepClone(x[i]));
13 | }
14 |
15 | return y;
16 |
17 | }
18 |
19 | const y = {};
20 |
21 | for (const key in x) {
22 | if (x.hasOwnProperty(key)) {
23 | y[key] = deepClone(x[key]);
24 | }
25 | }
26 |
27 | return y;
28 |
29 | }
--------------------------------------------------------------------------------
/src/modules/server/get-request.js:
--------------------------------------------------------------------------------
1 | import { hashString } from "../utils/hash-string.js";
2 | import { getCache, setCache } from "./internal/cache.js";
3 | import { makeCall } from "./internal/make-call.js";
4 |
5 | export function getRequest(url, cacheSeconds = 0, token = '') {
6 | const hash = hashString(url);
7 | return getCache(hash)
8 | .then(data => data)
9 | .catch(() => makeCall(url, 'GET', token).then(res => {
10 | cacheSeconds > 0 && setCache(hash, res, cacheSeconds);
11 | return res;
12 | }));
13 | }
--------------------------------------------------------------------------------
/src/modules/component/on-dragover.js:
--------------------------------------------------------------------------------
1 | export function onDragOver(element, onDrop) {
2 |
3 | element.addEventListener('dragenter', e => {
4 | element.classList.add('dragover');
5 | });
6 |
7 | element.addEventListener('dragleave', e => {
8 | element.classList.remove('dragover');
9 | });
10 |
11 | element.addEventListener('dragover', e => {
12 | e.preventDefault();
13 | });
14 |
15 | element.addEventListener('drop', e => {
16 | e.preventDefault();
17 | element.classList.remove('dragover');
18 | onDrop(e.dataTransfer.files);
19 | });
20 |
21 | }
--------------------------------------------------------------------------------
/src/modules/server/on-request.js:
--------------------------------------------------------------------------------
1 | import { ON_REQUEST_START, ON_REQUEST_STOP } from "./internal/make-call.js";
2 |
3 | export function onRequestStart(handler, urlIncludes = '*', once = false) {
4 | register(handler, urlIncludes, ON_REQUEST_START, once);
5 | }
6 |
7 | export function onRequestStop(handler, urlIncludes = '*', once = false) {
8 | register(handler, urlIncludes, ON_REQUEST_STOP, once);
9 | }
10 |
11 | function register(cb, includes, stack, once) {
12 | const event = {
13 | handler: once ? () => {
14 | cb();
15 | stack.delete(event);
16 | } : cb,
17 | includes: includes
18 | };
19 | stack.add(event);
20 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Sekoia.js
2 | Copyright (C) 2022 Jonathan M. Ochmann
3 |
4 | This program is free software: you can redistribute it and/or modify
5 | it under the terms of the GNU General Public License as published by
6 | the Free Software Foundation, either version 3 of the License, or
7 | (at your option) any later version.
8 |
9 | This program is distributed in the hope that it will be useful,
10 | but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | GNU General Public License for more details.
13 |
14 | You should have received a copy of the GNU General Public License
15 | along with this program. If not, see https://www.gnu.org/licenses.
--------------------------------------------------------------------------------
/src/modules/server/internal/cache.js:
--------------------------------------------------------------------------------
1 | import { PersistentStorage } from "../../state/PersistentStorage.js";
2 |
3 | const CACHE = () => CACHE.$$ || (CACHE.$$ = new PersistentStorage({
4 | name: 'sekoia::network::cache'
5 | }));
6 |
7 | export function setCache(hash, value, cacheSeconds) {
8 | return CACHE().set(hash, {
9 | value: value,
10 | expires: Date.now() + (cacheSeconds * 1000)
11 | });
12 | }
13 |
14 | export function getCache(hash) {
15 | return CACHE().get(hash).then(entry => {
16 | if (entry) {
17 | if (entry.expires < Date.now()) {
18 | CACHE().delete(hash);
19 | throw false;
20 | } else {
21 | return entry.value;
22 | }
23 | } else {
24 | throw false;
25 | }
26 | });
27 | }
--------------------------------------------------------------------------------
/src/modules/component/on-resize.js:
--------------------------------------------------------------------------------
1 | let RESIZE_OBSERVER, HANDLERS, RESIZE_BUFFER;
2 |
3 | export function onResize(element, handler) {
4 |
5 | if (element === window || element === document || element === document.documentElement) {
6 | element = document.body;
7 | }
8 |
9 | if ((HANDLERS || (HANDLERS = new Map())).has(element)) {
10 | HANDLERS.get(element).push(handler);
11 | } else {
12 | HANDLERS.set(element, [handler]);
13 | }
14 |
15 | (RESIZE_OBSERVER || (RESIZE_OBSERVER = new ResizeObserver(entries => {
16 | clearTimeout(RESIZE_BUFFER);
17 | RESIZE_BUFFER = setTimeout(ON_RESIZE, 100, entries);
18 | }))).observe(element);
19 |
20 | }
21 |
22 | function ON_RESIZE(entries) {
23 | for (let i = 0, entry, handlers; i < entries.length; i++) {
24 | entry = entries[i];
25 | handlers = HANDLERS.get(entry.target);
26 | if (handlers) {
27 | for (let k = 0; k < handlers.length; k++) {
28 | handlers[k](entry);
29 | }
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/src/modules/utils/deep-equal.js:
--------------------------------------------------------------------------------
1 | export function deepEqual(a, b) {
2 |
3 | if (a === b) {
4 | return true;
5 | }
6 |
7 | if (a && b && typeof a === 'object' && typeof b === 'object') {
8 |
9 | if (a.constructor !== b.constructor) return false;
10 |
11 | if (Array.isArray(a)) {
12 | if (a.length !== b.length) return false;
13 | for (let i = a.length; i-- !== 0;) {
14 | if (!deepEqual(a[i], b[i])) return false;
15 | }
16 | return true;
17 | }
18 |
19 | const keys = Object.keys(a);
20 | const length = keys.length;
21 |
22 | if (length !== Object.keys(b).length) return false;
23 |
24 | for (let i = length; i-- !== 0;) {
25 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false;
26 | }
27 |
28 | for (let i = length, key; i-- !== 0;) {
29 | key = keys[i];
30 | if (!deepEqual(a[key], b[key])) return false;
31 | }
32 |
33 | return true;
34 |
35 | }
36 |
37 | return a !== a && b !== b;
38 |
39 | }
--------------------------------------------------------------------------------
/src/modules/component/define-component.js:
--------------------------------------------------------------------------------
1 | import { ComponentModel } from "./internal/ComponentModel.js";
2 | import { ComponentElement } from "./internal/ComponentElement.js";
3 | import { ReactiveObject } from "../state/ReactiveObject.js";
4 |
5 | export function defineComponent(name, config) {
6 |
7 | const model = new ComponentModel(name, config);
8 |
9 | const component = class extends ComponentElement {
10 | constructor() {
11 | super(model);
12 | }
13 | };
14 |
15 | // add custom methods to prototype
16 | for (const key in config) {
17 | if (config.hasOwnProperty(key) && key !== 'initialize' && typeof config[key] === 'function') {
18 | component.prototype[key] = config[key];
19 | }
20 | }
21 |
22 | window.customElements.define(name, component);
23 |
24 | // creates composable html tag with attributes
25 | const Factory = attributes => ComponentModel.createTag(name, attributes);
26 |
27 | // creates a new state object
28 | Factory.state = data => {
29 | if (model.setupStateOnce()) {
30 | return ReactiveObject._from_(model.state, data);
31 | } else {
32 | return data;
33 | }
34 | };
35 |
36 | // creates dom node
37 | Factory.render = attributes => ComponentModel.createNode(name, attributes, Factory.state);
38 |
39 | return Factory;
40 |
41 | }
--------------------------------------------------------------------------------
/src/modules/state/internal/Binding.js:
--------------------------------------------------------------------------------
1 | export class Binding {
2 |
3 | // a link between reactive objects that are not in the same state branch
4 | constructor(sourceInternals, key, readonly) {
5 | this.sourceInternals = sourceInternals;
6 | this.ownPropertyName = key;
7 | this.readonly = readonly; // true when bound to upstream computed property
8 | this.connectedObjectInternals = new Map(); // [reactiveObjectInternals -> key]
9 | }
10 |
11 | connect(reactiveObjectInternals, boundKey) {
12 |
13 | if (reactiveObjectInternals === this.sourceInternals) {
14 | throw new Error(`Failed to bind "${boundKey}". Cannot bind object to itself.`);
15 | } else if (this.connectedObjectInternals.has(reactiveObjectInternals)) {
16 | throw new Error(`Failed to bind "${boundKey}". Cannot bind to an object more than once.`);
17 | } else {
18 | this.connectedObjectInternals.set(reactiveObjectInternals, boundKey);
19 | }
20 |
21 | }
22 |
23 | observeSource(callback, cancelable, silent) {
24 | return this.sourceInternals.observe(this.ownPropertyName, callback, cancelable, silent);
25 | }
26 |
27 | getValue(writableOnly) {
28 | return this.sourceInternals.getDatum(this.ownPropertyName, writableOnly);
29 | }
30 |
31 | getDefaultValue() {
32 | return this.sourceInternals.getDefaultDatum(this.ownPropertyName);
33 | }
34 |
35 | setValue(value, silent) {
36 | this.sourceInternals.setDatum(this.ownPropertyName, value, silent);
37 | }
38 |
39 | }
40 |
41 | Binding.prototype._isBinding_ = true;
--------------------------------------------------------------------------------
/src/modules/server/internal/make-call.js:
--------------------------------------------------------------------------------
1 | const PENDING_CALLS = new Map();
2 |
3 | export const ON_REQUEST_START = new Set();
4 | export const ON_REQUEST_STOP = new Set();
5 |
6 | export function makeCall(url, method, token, data = {}) {
7 |
8 | if (PENDING_CALLS.has(url)) {
9 |
10 | return PENDING_CALLS.get(url);
11 |
12 | } else {
13 |
14 | const headers = {
15 | 'Content-Type': 'application/json'
16 | };
17 |
18 | if (token) {
19 | headers['Authorization'] = `Bearer ${token}`;
20 | }
21 |
22 | fire(ON_REQUEST_START, url);
23 |
24 | return PENDING_CALLS.set(url, fetch(url, {
25 | method: method,
26 | mode: 'cors',
27 | cache: 'no-store',
28 | credentials: 'same-origin',
29 | headers: headers,
30 | redirect: 'follow',
31 | referrer: 'no-referrer',
32 | body: method === 'GET' ? null : typeof data === 'string' ? data : JSON.stringify(data)
33 | }).then(res => {
34 | const ct = res.headers.get('content-type');
35 | const fn = ct && ct.includes('application/json') ? 'json' : 'text';
36 | if (!res.ok) {
37 | return res[fn]().then(x => {
38 | throw x;
39 | });
40 | } else {
41 | if (res.status === 204) {
42 | return {};
43 | } else {
44 | return res[fn]();
45 | }
46 | }
47 | }).finally(() => {
48 | PENDING_CALLS.delete(url);
49 | fire(ON_REQUEST_STOP, url);
50 | })).get(url);
51 |
52 | }
53 |
54 | }
55 |
56 | function fire(events, url) {
57 | for (const event of events) {
58 | if (event.includes === '*' || url.includes(event.includes)) {
59 | event.handler();
60 | }
61 | }
62 | }
--------------------------------------------------------------------------------
/src/modules/state/internal/ReactiveWrapper.js:
--------------------------------------------------------------------------------
1 | import { deepClone } from "../../utils/deep-clone.js";
2 |
3 | export class ReactiveWrapper {
4 |
5 | constructor(internal) {
6 | Object.defineProperty(this, '$$', {
7 | value: internal
8 | });
9 | }
10 |
11 | get(key) {
12 | if (key === void 0) {
13 | return this.$$.getData(false);
14 | } else {
15 | return this.$$.getDatum(key, false);
16 | }
17 | }
18 |
19 | default(key) {
20 | // return deep clone of writable default values
21 | if (key === void 0) {
22 | return deepClone(this.$$.getDefaultData());
23 | } else {
24 | return deepClone(this.$$.getDefaultDatum(key));
25 | }
26 | }
27 |
28 | snapshot(key) {
29 |
30 | // return a deep clone of writable data
31 | if (key === void 0) {
32 |
33 | // getData(true) already returns a shallow copy...
34 | const copy = this.$$.getData(true);
35 |
36 | // ...make it deep
37 | if (Array.isArray(copy)) {
38 | for (let i = 0; i < copy.length; i++) {
39 | copy[i] = deepClone(copy[i]);
40 | }
41 | } else {
42 | for (const key in copy) {
43 | if (copy.hasOwnProperty(key)) {
44 | copy[key] = deepClone(copy[key]);
45 | }
46 | }
47 | }
48 |
49 | return copy;
50 |
51 | } else {
52 |
53 | return deepClone(this.$$.getDatum(key, true));
54 |
55 | }
56 |
57 | }
58 |
59 | set(key, value) {
60 | if (typeof key === 'object') {
61 | this.$$.setData(key);
62 | } else {
63 | this.$$.setDatum(key, value);
64 | }
65 | }
66 |
67 | reset(key) {
68 | if (key === void 0) {
69 | this.set(this.default());
70 | } else {
71 | this.set(key, this.default(key));
72 | }
73 | }
74 |
75 | }
--------------------------------------------------------------------------------
/src/modules/state/internal/ReactiveObjectModel.js:
--------------------------------------------------------------------------------
1 | import { Core } from "./Core.js";
2 | import { ComputedProperty } from "./ComputedProperty.js";
3 |
4 | export class ReactiveObjectModel {
5 |
6 | constructor(properties) {
7 |
8 | this.instances = 0;
9 | this.nativeData = {};
10 | this.privateKeys = new Set();
11 | this.boundProperties = new Map();
12 | this.computedProperties = new Map();
13 |
14 | let isPrivate;
15 | for (const key in properties) {
16 |
17 | if (properties.hasOwnProperty(key)) {
18 |
19 | if (key.indexOf('_') === 0) {
20 | this.privateKeys.add(key);
21 | isPrivate = true;
22 | } else {
23 | isPrivate = false;
24 | }
25 |
26 | const value = properties[key];
27 |
28 | if (value?._isBinding_) {
29 | // It is possible to attach bindings to private, readonly computed properties.
30 | // It is not possible to attach bindings to non-computed private properties
31 | // since the private key value would leak to the bindings source object.
32 | if (isPrivate && !value.readonly) {
33 | throw new Error(`Can not bind("${value.ownPropertyName}") to private key "${key}" because it is only accessible by this object.`);
34 | } else {
35 | this.boundProperties.set(key, value);
36 | }
37 | } else if (typeof value === 'function') {
38 | this.computedProperties.set(key, new ComputedProperty(key, isPrivate, value, [], null));
39 | } else {
40 | this.nativeData[key] = value;
41 | }
42 |
43 | }
44 |
45 | }
46 |
47 | if (this.computedProperties.size) {
48 | this.computedProperties = Core.setupComputedProperties(properties, this.computedProperties);
49 | }
50 |
51 | }
52 |
53 | }
--------------------------------------------------------------------------------
/gulpfile.js:
--------------------------------------------------------------------------------
1 | const {series, src, dest} = require('gulp');
2 | const rollup = require('gulp-better-rollup');
3 | const minify = require('gulp-terser');
4 | const rename = require('gulp-rename');
5 | const iife = require('gulp-iife');
6 | const footer = require('gulp-footer');
7 | const removeCode = require('gulp-remove-code');
8 |
9 | function buildIIFE() {
10 | return src('src/index.js')
11 | .pipe(rollup({}, 'esm'))
12 | .pipe(rename('sekoia.js'))
13 | .pipe(iife({
14 | useStrict: false,
15 | trimCode: true,
16 | prependSemicolon: false,
17 | bindThis: false,
18 | params: ['window'],
19 | args: ['window || this']
20 | }))
21 | .pipe(dest('build'));
22 | }
23 |
24 | function minifyIIFE() {
25 | return src('build/sekoia.js')
26 | .pipe(minify({
27 | mangle: {
28 | toplevel: true,
29 | keep_fnames: false,
30 | properties: {
31 | regex: new RegExp('^__') // properties and methods starting with two underscores are mangled
32 | }
33 | },
34 | output: {
35 | comments: false
36 | }
37 | }))
38 | .pipe(rename('sekoia.min.js'))
39 | .pipe(dest('build'));
40 | }
41 |
42 | function buildModule() {
43 | return src('src/index.js')
44 | .pipe(rollup({}, 'esm'))
45 | .pipe(removeCode({esModule: true}))
46 | .pipe(footer(`export {
47 | createElement,
48 | defineComponent,
49 | onResize,
50 | onDragOver,
51 | renderList,
52 | Router,
53 | deleteRequest,
54 | getRequest,
55 | onRequestStart,
56 | onRequestStop,
57 | postRequest,
58 | putRequest,
59 | createState,
60 | PersistentStorage,
61 | ReactiveArray,
62 | ReactiveObject,
63 | deepClone,
64 | deepEqual,
65 | hashString,
66 | throttle,
67 | defer
68 | }`))
69 | .pipe(rename('sekoia.module.js'))
70 | .pipe(dest('build'));
71 | }
72 |
73 | exports.build = series(buildIIFE, minifyIIFE, buildModule);
74 |
--------------------------------------------------------------------------------
/src/modules/state/internal/ComputedProperty.js:
--------------------------------------------------------------------------------
1 | import { deepEqual } from "../../utils/deep-equal.js";
2 |
3 | export class ComputedProperty {
4 |
5 | constructor(ownPropertyName, isPrivate, computation, sourceProperties, sourceProxy) {
6 |
7 | this.ownPropertyName = ownPropertyName;
8 | this.isPrivate = isPrivate;
9 | this.computation = computation; // the function that computes a result from data points on the source
10 |
11 | // Dependency Graph
12 | this.sourceProperties = sourceProperties; // property names this computedProperty depends on
13 | this.sourceProxy = sourceProxy; // proxy object
14 |
15 | // Value Cache
16 | this.intermediate = void 0; // intermediate computation result
17 | this.value = void 0; // current computation result
18 |
19 | // Optimization flags
20 | this.needsUpdate = true; // flag indicating that one or many dependencies have updated and value needs to re-compute
21 | this.hasChanged = false; // flag indicating that the computation has yielded a new result (used by event-queue)
22 |
23 | }
24 |
25 | clone(sourceProxy) {
26 | return new this.constructor(this.ownPropertyName, this.isPrivate, this.computation, this.sourceProperties, sourceProxy);
27 | }
28 |
29 | getValue() {
30 |
31 | if (this.needsUpdate) { // re-compute because dependencies have updated
32 |
33 | // call computation with first argument = source data proxy, second argument = current value
34 | this.intermediate = this.computation(this.sourceProxy, this.value);
35 |
36 | if (!deepEqual(this.intermediate, this.value)) {
37 |
38 | // Computations should never produce side-effects (non-enforced convention)
39 | // so we don't have to do defensive cloning here. Just swap the pointer or primitive.
40 | this.value = this.intermediate;
41 |
42 | this.hasChanged = true;
43 |
44 | } else {
45 |
46 | this.hasChanged = false;
47 |
48 | }
49 |
50 | this.needsUpdate = false;
51 |
52 | }
53 |
54 | return this.value;
55 |
56 | }
57 |
58 | }
--------------------------------------------------------------------------------
/src/modules/state/internal/StateTracker.js:
--------------------------------------------------------------------------------
1 | import { deepEqual } from "../../utils/deep-equal.js";
2 |
3 | export class StateTracker {
4 |
5 | constructor(onTrack, maxEntries = 100) {
6 | this.__stack = [];
7 | this.__index = 0;
8 | this.__recursive = false;
9 | this.__max = maxEntries;
10 | this.__onTrack = onTrack;
11 | }
12 |
13 | prev() {
14 | return this.__index - 1;
15 | }
16 |
17 | next() {
18 | return this.__index + 1;
19 | }
20 |
21 | has(index) {
22 | if (index < 0 || !this.__stack.length) {
23 | return false;
24 | } else {
25 | return index <= this.__stack.length - 1;
26 | }
27 | }
28 |
29 | get(index) {
30 |
31 | if (index !== this.__index) {
32 |
33 | this.__recursive = true;
34 | this.__index = index;
35 |
36 | if (this.__onTrack) {
37 | // callback value, index, length
38 | this.__onTrack(this.__stack[index], index, this.__stack.length);
39 | }
40 |
41 | }
42 |
43 | return this.__stack[index];
44 |
45 | }
46 |
47 | add(state, checkUniqueness) {
48 |
49 | if (this.__recursive) {
50 |
51 | this.__recursive = false;
52 |
53 | } else {
54 |
55 | state = state?.$$ ? state.snapshot() : state;
56 |
57 | if (checkUniqueness && deepEqual(state, this.__stack[this.__index])) {
58 |
59 | return false;
60 |
61 | } else {
62 |
63 | // history modification: remove everything after this point
64 | if (this.__index + 1 < this.__stack.length) {
65 | this.__stack.splice(this.__index + 1, this.__stack.length - this.__index - 1);
66 | }
67 |
68 | // maxed out: remove items from beginning
69 | if (this.__stack.length === this.__max) {
70 | this.__stack.shift();
71 | }
72 |
73 | // append and move marker to last position
74 | this.__stack.push(state);
75 | this.__index = this.__stack.length - 1;
76 |
77 | if (this.__onTrack) {
78 | this.__onTrack(this.__stack[this.__index], this.__index, this.__stack.length);
79 | }
80 |
81 | return true;
82 |
83 | }
84 |
85 | }
86 |
87 | }
88 |
89 | }
--------------------------------------------------------------------------------
/src/modules/state/internal/Queue.js:
--------------------------------------------------------------------------------
1 | const REQUEST = window.requestAnimationFrame;
2 |
3 | export const Queue = {
4 |
5 | tick: 0,
6 | keyEvents: new Map(),
7 | wildcardEvents: new Map(),
8 | computedProperties: new Set(),
9 | dependencies: new Set(),
10 | resolvedComputedProperties: new Set(),
11 |
12 | flush() {
13 | if (!this.__scheduled) {
14 | this.__scheduled = REQUEST(this.__flushOnNextTick);
15 | }
16 | },
17 |
18 | throttle(fn, interval) {
19 | // creates a function throttled to internal flush tick
20 | let last = -9999999;
21 | return arg => {
22 | const now = this.tick;
23 | if (now - last > interval) {
24 | last = now;
25 | return fn(arg);
26 | }
27 | }
28 | },
29 |
30 | // -----------------------------------
31 |
32 | __scheduled: 0,
33 | __keyEvents: new Map(),
34 | __wildcardEvents: new Map(),
35 |
36 | __flushOnNextTick(tick) {
37 |
38 | this.tick = tick;
39 |
40 | const keyEvents = this.keyEvents;
41 |
42 | if (keyEvents.size) {
43 |
44 | // swap public buffer so events can re-populate
45 | // it in recursive write operations
46 | this.keyEvents = this.__keyEvents;
47 | this.__keyEvents = keyEvents;
48 |
49 | for (const [callback, value] of keyEvents) {
50 | callback(value);
51 | keyEvents.delete(callback);
52 | }
53 |
54 | }
55 |
56 | const wildcardEvents = this.wildcardEvents;
57 |
58 | if (wildcardEvents.size) {
59 |
60 | this.wildcardEvents = this.__wildcardEvents;
61 | this.__wildcardEvents = wildcardEvents;
62 |
63 | for (const [callback, owner] of wildcardEvents) {
64 | callback(owner);
65 | wildcardEvents.delete(callback);
66 | }
67 |
68 | }
69 |
70 | // events can re-populate these buffers because they are
71 | // allowed to change state in reaction to another state change.
72 | if (this.keyEvents.size || this.wildcardEvents.size) {
73 | this.__flushOnNextTick(tick); // pass same rAF tick
74 | } else {
75 | this.__scheduled = 0;
76 | }
77 |
78 | }
79 |
80 | };
81 |
82 | Queue.__flushOnNextTick = Queue.__flushOnNextTick.bind(Queue);
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // Component
2 | import { createElement } from "./modules/component/create-element.js";
3 | import { defineComponent } from "./modules/component/define-component.js";
4 | import { onResize } from "./modules/component/on-resize.js";
5 | import { onDragOver } from "./modules/component/on-dragover.js";
6 | import { renderList } from "./modules/component/render-list.js";
7 |
8 | // Router
9 | import { Router } from "./modules/router/router.js";
10 |
11 | // Server
12 | import { deleteRequest } from "./modules/server/delete-request.js";
13 | import { getRequest } from "./modules/server/get-request.js";
14 | import { onRequestStart, onRequestStop } from "./modules/server/on-request.js";
15 | import { postRequest } from "./modules/server/post-request.js";
16 | import { putRequest } from "./modules/server/put-request.js";
17 |
18 | // Store
19 | import { createState } from "./modules/state/create-state.js";
20 | import { PersistentStorage } from "./modules/state/PersistentStorage.js";
21 | import { ReactiveArray } from "./modules/state/ReactiveArray.js";
22 | import { ReactiveObject } from "./modules/state/ReactiveObject.js";
23 |
24 | // Utils
25 | import { deepClone } from "./modules/utils/deep-clone.js";
26 | import { deepEqual } from "./modules/utils/deep-equal.js";
27 | import { hashString } from "./modules/utils/hash-string.js";
28 | import { throttle } from "./modules/utils/throttle.js";
29 | import { defer } from "./modules/utils/defer.js";
30 |
31 | //removeIf(esModule)
32 | const Sekoia = {
33 | createElement,
34 | defineComponent,
35 | onResize,
36 | onDragOver,
37 | renderList,
38 | Router,
39 | deleteRequest,
40 | getRequest,
41 | onRequestStart,
42 | onRequestStop,
43 | postRequest,
44 | putRequest,
45 | createState,
46 | PersistentStorage,
47 | ReactiveArray,
48 | ReactiveObject,
49 | deepClone,
50 | deepEqual,
51 | hashString,
52 | throttle,
53 | defer
54 | };
55 |
56 | if (typeof module === 'object' && typeof module.exports === 'object') {
57 | module.exports = Sekoia;
58 | } else if (typeof define === 'function' && define.amd) {
59 | define('Sekoia', [], function() {
60 | return Sekoia;
61 | });
62 | } else {
63 | window.Sekoia = Sekoia;
64 | }
65 | //endRemoveIf(esModule)
66 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Scalable reactive application architecture built on living web standards with a focus on micro-optimized performance, semantic APIs and customElements that don't suck. 🥺 👉👈
4 |
5 |
6 |
Sekoia.js // Vanilla ES6 - All major browsers
7 |
8 | ### 🧬 Micro-optimized reactivity engine
9 | Sekoia is powered by an advanced reactivity engine that enhances serializable data with first-class observability, time travel, shape and type consistency and insanely optimized state diffing.
10 |
11 | - [Documentation](./src/modules/state)
12 | - [TodoMVC Live Demo](https://codepen.io/monokee/project/editor/XxWGbV#)
13 | - [TodoMVC Source](./examples/TODO%20MVC)
14 |
15 | ### ⚡️ Data driven custom elements
16 | Sekoia gives your UI code structure by making native customElements reactive and composable while facilitating architecturally clean access to the DOM.
17 |
18 | - [Documentation](./src/modules/component)
19 | - [TodoMVC Live Demo](https://codepen.io/monokee/project/editor/XxWGbV#)
20 | - [TodoMVC Source](./examples/TODO%20MVC)
21 |
22 | ***
23 |
24 | ### Router
25 | Pretty advanced hash-based router for SPAs with reactive route actions and
26 | route filtering for conditional re-routes. I don't have time to document it rn.
27 |
28 | ### Server
29 | Simple REST API helper with request buffering and indexedDB caching plus cache expiration.
30 | Nothing too fancy.
31 |
32 | ### Utils
33 | Common functions that are shared by internal modules and are also useful for implementation
34 | code of high-performance web apps.
35 |
36 | ### License
37 | ```
38 | Sekoia.js
39 | Copyright (C) 2022 Jonathan M. Ochmann
40 |
41 | This program is free software: you can redistribute it and/or modify
42 | it under the terms of the GNU General Public License as published by
43 | the Free Software Foundation, either version 3 of the License, or
44 | (at your option) any later version.
45 |
46 | This program is distributed in the hope that it will be useful,
47 | but WITHOUT ANY WARRANTY; without even the implied warranty of
48 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
49 | GNU General Public License for more details.
50 |
51 | You should have received a copy of the GNU General Public License
52 | along with this program. If not, see https://www.gnu.org/licenses.
53 | ```
54 | ***
55 | ### Author
56 | Jonathan M. Ochmann (@monokee)
57 |
`)
51 | }
52 | },
53 | title: {
54 | value: ({users}) => users.length ? 'Our Users' : 'We have no users...',
55 | render({$title}, value) {
56 | $title.textContent = value;
57 | }
58 | }
59 | },
60 |
61 | initialize({$deleteButton}) {
62 | $deleteButton.addEventListener('click', () => {
63 | this.state.get('users').clear();
64 | });
65 | }
66 |
67 | });
68 | ```
69 |
70 | Let's break down the Component piece by piece:
71 |
72 | ### 🖼 Component.element
73 | Plain old static HTML - your components' skeleton.
74 | Your markup should be truly static. Don't include anything that should dynamically change here. We'll take care of dynamic parts later.
75 | ```javascript
76 | {
77 | element: `
78 |
79 |
80 |
81 | `
82 | }
83 | ```
84 |
85 | > Note the special "$" attribute. Sekoia automatically parses your component and passes these "$refs" to all render and lifecycle callbacks
86 | for pre-cached programmatic access. (Yes, inspired by jQuery, and I'm not ashamed to admit it).
87 | ***
88 | ### 🎨 Component.style
89 | Plain old CSS - with a twist.
90 | The CSS written here will be softly scoped to your component. Soft scoping means that outside, global CSS can still reach into the
91 | component for global theming etc via classes. Sekoia simply prepends all selectors with the tag name of the component.
92 | Refs like "$title" can be used as style selectors as is.
93 |
94 | With that in mind Sekoia will internally convert:
95 | ```css
96 | $self {
97 | position: absolute;
98 | }
99 | $self:hover {
100 | opacity: 0.75;
101 | }
102 | $title {
103 | text-transform: uppercase;
104 | }
105 | ```
106 | into:
107 |
108 | ```css
109 | my-component {
110 | position: absolute;
111 | }
112 | my-component:hover {
113 | opacity: 0.75;
114 | }
115 | my-component .title0 {
116 | text-transform: uppercase;
117 | }
118 | ```
119 | ...and append these rules to a global stylesheet that is used by all instances of the component.
120 | Note that $title has been re-written to a runtime globally unique classname.
121 |
122 | ##### Escaping scope
123 | You may want to style your components based on global classes attached to an ancestor element like
124 | ```body``` while keeping all of your style definitions inside of the component. That's easy:
125 | ```css
126 | /* Escape component scope via :root */
127 | :root body.isLandscape $self {
128 | position: absolute;
129 | }
130 | /* Becomes -> */
131 | body.isLandscape my-component {
132 | position: absolute;
133 | }
134 | ```
135 |
136 | ***
137 |
138 | ### 🧬 Component.state
139 |
140 | Think of state as a simple, high-level description of the moving parts of your component.
141 | This is the data that components need to somehow display to the user.
142 |
143 | > Internally components consume Sekoia's observable reactive state modules. For an in-depth
144 | understanding of state modeling and reactivity concepts see:
145 | [Reactive State Documentation](../state)
146 |
147 | ### 🎞 Rendering
148 |
149 | *A built-in shortcut for state.observe(property)*
150 |
151 | Render callbacks are reactions that fire in response to data changes and update fragments of DOM.
152 | ```javascript
153 | state: {
154 |
155 | title: {
156 | value: ({users}) => users.length ? 'Our Users' : 'We have no users...',
157 | render({$title}, value) {
158 | $title.textContent = value;
159 | }
160 | },
161 |
162 | users: {
163 | value: [
164 | {firstName: 'John', lastName: 'Williams'},
165 | {firstName: 'Hans', lastName: 'Zimmer'}
166 | ],
167 | render({$userList}, user) {
168 | $userList.innerHTML = value.map(user => (`
${user.firstName}
`));
169 | }
170 | }
171 |
172 | }
173 | ```
174 | Render callbacks receive an object of all "$ref" elements as their first argument. For convenience, you can destructure the elements
175 | you need to manipulate directly in the parameter.
176 | The second parameter is the value of the data property that has changed in the state and subsequently triggered the reaction.
177 |
178 | The single responsibility of reactions is to update the DOM. You cannot access "this" inside of reactions for "this" reason.
179 | All you should need is the $ref elements you want to update with the value of the data property that has changed.
180 |
181 | > When you work with $refs, you directly target real DOM nodes - there is no abstraction layer - render callbacks thus offer
182 | incredible performance.
183 | And because these callbacks are only running in response to actual changes of the data model, even complex Sekoia Components never become
184 | slow, hard to predict or maintain.
185 |
186 | #### List rendering and reconciliation
187 | For high-performance list rendering the ```render``` property should be a configuration object
188 | that Sekoia internally passes to the high performance DOM reconciler.
189 |
190 | - See the [TodoMVC Example](../../../examples/TODO%20MVC) for an implementation
191 | ````javascript
192 | users: {
193 | value: new ReactiveArray([
194 | {firstName: 'John', lastName: 'Williams'},
195 | {firstName: 'Hans', lastName: 'Zimmer'}
196 | ]),
197 | render: {
198 | parentElement: '$userList', // ref name
199 | createChild: user => {
200 | // requried: receives data entry from array and returns a DOM node
201 | return createElement(`
${user.firstName} ${user.lastName}
`)
202 | },
203 | updateChild: ($child, user) => {
204 | // optional: update child node in-place with new data
205 | }
206 | }
207 | }
208 | ````
209 | The ```value``` property should be an instance of ```ReactiveArray```. When the child elements are Sekoia
210 | Components with their own internal state, pass the state factory to ```ReactiveArray``` as an instantiation model.
211 |
212 | The render configuration object requires a ```createChild``` method which turns each entry in the data array into a DOM Node.
213 | Sekoia is powered by an ultra-fast reconciliation algorithm under the hood that updates only those parts of the DOM that
214 | were affected by changes in data. It works for mutable and immutable data and will be fast whether you push() into an
215 | array or whether you prefer immutable data and replace the entire array. You can optionally speed up the reconciliation
216 | even further by providing an ```"updateElement"``` function which updates elements in-place instead of replacing them.
217 | ***
218 |
219 | ### ⚙️ Setting and getting state
220 | You can read and write state data inside of lifecycle and custom top-level methods via ```this.state```.
221 |
222 | ```MyComponent.state.get('property') | MyComponent.state.set('property', value) | MyComponent.state.set({prop: value, prop: value})```
223 |
224 | Whenever the value of a state property changes, its corresponding render callback is added to Sekoia's asynchronous render queue.
225 | Sekoia automatically determines if the value has actually changed and only then queues up the reaction.
226 | And if for whatever reason the value changes one million times before the next frame is rendered,
227 | the reaction will still only be fired once with the most recent value - thanks to Sekoia's powerful auto-buffering renderer.
228 |
229 | > Note: State only accepts data that matches the shape and type of the state model's default data.
230 | See [Reactive State Documentation](../state) for details
231 |
232 | ***
233 |
234 | ### 👋 Component.initialize
235 | The only Lifecycle method Sekoia Components need is called once per component instance, after the component has been
236 | inserted into the DOM. Receives "$refs" as first and only argument. Typically, this is where you would bind input events,
237 | retrieve server data etc.
238 |
239 | ```javascript
240 | initialize({$self, $toggle, $list}) {
241 |
242 | // bind events to $refs...
243 | $toggle.addEventListener('click', e => {
244 | this.handleClick();
245 | });
246 |
247 | // ... or delegate to the component itself
248 | this.addEventListener('click', e => {
249 | if ($toggle.contains(e.target)) {
250 | this.handleClick();
251 | }
252 | });
253 |
254 | console.assert($self === this);
255 |
256 | },
257 |
258 | handleClick() {
259 | // The state's render callback will update the DOM in response.
260 | this.state.set('active', !this.state.get('active'));
261 | }
262 | ```
263 |
264 | ***
265 |
266 | ### 🪆 Composing Components
267 | There are multiple ways Sekoia helps with composing customElements. The first and probably least obvious, is that
268 | Sekoia Components do not use shadow DOM. Everything inside ```element``` is composed into the elements light DOM. That
269 | allows us to easily compose other components into our markup by including the components tag name.
270 |
271 | #### Factory({...attributes})
272 | For convenience and first-class ES module support, ```defineComponent``` returns a factory function that
273 | can be used to render a component's tag name along with attributes into the markup of another component.
274 | A common pattern looks like this:
275 |
276 | child-component.js:
277 |
278 | ```javascript
279 | export const ChildComponent = defineComponent('child-component', {
280 | element: (`
281 |
282 |
283 |
284 | `)
285 | });
286 | ```
287 |
288 | parent-component.js:
289 |
290 | ```javascript
291 | import { ChildComponent } from './child-component.js';
292 |
293 | const ParentComponent = defineComponent('parent-component', {
294 | element: (`
295 | Hello world
296 | ${ChildComponent({class: 'pos-rel flex-row'})}
297 | `)
298 | });
299 | ```
300 |
301 | You can pass an attribute object to the factory function returned by ```defineComponent```
302 | and it's properties will be mapped to standard DOM attributes. It's also possible to inject state
303 | into a component via the attributes object. You don't have to serialize the state object - Sekoia
304 | uses temporary attribute reflection to inject the state into the component when it is attached
305 | to the DOM.
306 | ```javascript
307 | ChildComponent({
308 | class: 'pos-rel pd-250',
309 | style: 'will-change: opacity', // use inline dom-strings
310 | state: { // state can be object
311 | user: {
312 | name: 'Jonathan'
313 | }
314 | }
315 | });
316 |
317 | // returns: 'deferred until the last possible moment. Multiple instances of
338 | components share the same ComponentModel prototype and template for greatly optimized memory consumption and rendering performance.
339 | When the first instance of a Component is attached to the DOM for the first time, the ComponentModel sets itself up once by
340 | scoping the CSS, collecting $refs and setting up the state model. All subsequent instances of the Component pull from the
341 | ComponentModel's pooled template, styles and state model with no additional roundtrips to expensive DOM APIs or state resolvers.
342 |
343 | > The extensive pooling and caching of resolved data structures allows Sekoia Components to outperform most frameworks and
344 | > even native customElements while providing clean architectural conventions for scalable application design.
345 |
346 |
347 |
348 | ### License
349 | ```
350 | Sekoia.js
351 | Copyright (C) 2022 Jonathan M. Ochmann
352 |
353 | This program is free software: you can redistribute it and/or modify
354 | it under the terms of the GNU General Public License as published by
355 | the Free Software Foundation, either version 3 of the License, or
356 | (at your option) any later version.
357 |
358 | This program is distributed in the hope that it will be useful,
359 | but WITHOUT ANY WARRANTY; without even the implied warranty of
360 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
361 | GNU General Public License for more details.
362 |
363 | You should have received a copy of the GNU General Public License
364 | along with this program. If not, see https://www.gnu.org/licenses.
365 | ```
366 | ***
367 | ### Author
368 | Jonathan M. Ochmann (@monokee)
369 |
375 |
376 | ***
377 | Made with ♥️ in CGN | (C) Patchflyer GmbH 2014-2049
--------------------------------------------------------------------------------
/src/modules/state/README.md:
--------------------------------------------------------------------------------
1 | # Sekoia Reactivity Engine
2 | Reactive, observable state with pooled models and fully automatic type safety.
3 |
4 | - Micro-optimized, fully observable Objects and Arrays
5 | - Computed properties with smart caching
6 | - External state bindings
7 | - ATS Automatic Type Safety
8 | - Built-in time travel (undo, redo, etc)
9 | - Semantic Private Properties
10 | - Async Observer resolution
11 | - Built-in defer/throttle hooks
12 |
13 | Sekoia exports ```ReactiveObject``` and ```ReactiveArray```. These classes are driving the
14 | reactivity of Sekoia Components but are designed as general purpose state containers that
15 | can be used for any kind of reactive and observable data.
16 |
17 | ## 👨💻 Getting started
18 |
19 | Let's create a simple reactive state tree:
20 | ```javascript
21 | import { ReactiveObject, ReactiveArray } from './store';
22 |
23 | const State = new ReactiveObject({
24 | firstName: 'Jonathan',
25 | lastName: 'Ochmann',
26 | nickName: 'Jon',
27 | fullName: ({firstName, lastName}) => `${firstName} ${lastName}`,
28 | greeting: ({fullName, nickName}) => `Hey, I'm ${fullName} but my friends call me ${nickName}!`,
29 | friends: new ReactiveArray([], {
30 | model: data => State.clone(data)
31 | })
32 | });
33 | ```
34 | Now we can [observe](#observekey-callback-options--cancelable-false-throttle-0--defer-0) any changes to this state, [track](#trackkey-options--maxentries-100-ontrack-fn-throttle-0--defer-0) mutations over time, stamp out serializable
35 | [snapshots](#snapshotkey), [reset](#resetkey) to default and create new instances of the state model via [cloning](#clonedata).
36 |
37 | ### Overview
38 |
39 | There are a few concepts that distinguish ReactiveObjects from regular objects and Sekoia's reactive architecture from
40 | traditional state modeling:
41 | - Calling new ReactiveObject() creates both an instance and a model based on the property object passed into the constructor.
42 | - Additional instances of the object are created by [cloning](#clonedata) the object with new data.
43 | - This gives your state default data - so it can be [reset](#resetkey) with one line of code.
44 | - Defaults allow Sekoia to [infer the type of primitives and the shape of objects](#setkey-value) in your model. No need for additional type checking.
45 | - Based on the type and shape information, Sekoia will automatically reject data that doesn't [fit the model.](#setkey-value)
46 |
47 | Sekoia caches property types, shape information and computed property resolution in a pooled model that is re-used by all instances of the reactive object.
48 | Nested reactive objects have to be created explicitly - normal objects and arrays inside of reactive state models are treated like immutable primitives and are deep-compared by value,
49 | meaning they can only be overwritten in their entirety. Nested reactive objects bubble their internal changes to their reactive parent objects.
50 | Bubbling can be prevented by making properties [private](#-private-properties).
51 |
52 | #### 🧮 Computed Properties
53 | You can specify computed properties simply by adding a pure function as a property value.
54 | ```javascript
55 | const State = new ReactiveObject({
56 | prop: 1,
57 | plusOne: ({prop}) => prop + 1
58 | });
59 | ```
60 | Computed property functions receive an object containing all other properties (including other computed properties) as the
61 | first and only argument. You should always destructure the properties your computation depends on
62 | directly in the parameter. This ensures that all dependencies can be resolved.
63 | The computation should return its result.
64 | Computed properties can be derived from other computed properties, a mix of computed and non-computed properties etc. Evaluation
65 | of computed properties is optimized so that they are only re-evaluated when any of their immediate dependencies have changed.
66 | Nothing in Sekoia is ever re-computed or observed when it's not absolutely necessary.
67 |
68 | #### 🔁 Bound Properties
69 | You can directly [bind](#bindkey) any property in your state model to other reactive objects.
70 | This allows different data stores to share and access the exact same data. When the data in a store changes,
71 | all objects which bind to the changed property in the store are automatically updated.
72 | ```javascript
73 | const MyStore = new ReactiveObject({
74 | thePropertyInTheStore: 123
75 | });
76 |
77 | const HerStore = new ReactiveObject({
78 | storeBound: MyStore.bind('thePropertyInTheStore')
79 | });
80 | ```
81 | Note that any computations and observers will be fired when the data in the store changes. The data can change
82 | by directly calling ```ExternalStore.set('thePropertyInTheStore', value)``` or, when dealing with internal [Component State](../component) which is bound to the Store via
83 | ```Component.state.set('storeBound', value)```
84 | It is not possible to bind _private properties. It is only possible to bind to private properties if the bound
85 | property is a readonly computed property.
86 |
87 | #### 🥷 Private Properties
88 | Prefixing keys with an underscore marks properties in ReactiveObjects as private.
89 | Private properties:
90 |
91 | - Do not trigger [wildcard observers](#observekey-callback-options--cancelable-false-throttle-0--defer-0).
92 | - Do not propagate changes to parent ReactiveObject or ReactiveArray
93 | - Can not be bound to other objects
94 | - Can bind to non-private computed properties on other ReactiveObjects
95 | - Are accessible like any other properties via get('_property')
96 |
97 | ***
98 |
99 | ## Methods
100 | These methods are available on all instances of ReactiveObject and ReactiveArray.
101 | The expected behaviour is documented for ReactiveObject first with some [special exceptions for ReactiveArray](#reactivearray-specialties) at the bottom of the section.
102 |
103 | ##### get([key])
104 | returns data of key or entire object if key is undefined.
105 | > When retrieving nested ReactiveObjects or ReactiveArrays, the actual Reactive interface is returned.
106 | > To retrieve plain, serializable data call snapshot() on the returned interface or retrieve a snapshot
107 | > directly from the reactive parent.
108 |
109 | ##### default([key])
110 | Returns a deep clone the initial data value that was passed when the model was defined on the first object instantiation.
111 | Returns entire object when no key is provided.
112 |
113 | ##### snapshot([key])
114 | Returns a deep clone of writable (serializable) properties. Useful for persistence and immutable state updates.
115 | ```javascript
116 | State.snapshot() === {
117 | firstName: 'Jonathan',
118 | lastName: 'Ochmann',
119 | nickname: 'Jon',
120 | friends: []
121 | }
122 | ```
123 |
124 | ##### set([key], value)
125 | Assigns target data to object if target matches the type (in case of primitives) or shape (in case of objects) of the state model.
126 | The algorithm works with arbitrarily nested data structures consisting of { plain: objects } and [ plain, arrays ].
127 | ```javascript
128 | State.set('firstName', 'Terry');
129 |
130 | State.set({
131 | firstName: 'Terry',
132 | lastName: 'Gilliam',
133 | whatever: [] // will be ignored because it's not part of the model
134 | });
135 |
136 | // won't do anything. firstName must be String.
137 | State.set('firstName', 123);
138 |
139 | // will throw - Do not set computed properties (duh!)
140 | State.set('greeting', 'Goodbye');
141 | ```
142 | > **Equality rules for patching data:**
143 | > Let source be the ReactiveObject and target be invading data.
144 | > When source and target are both primitives, their type must match, but their value must be different in order to be assigned.
145 | > When source and target are objects, the algorithm recursively applies the target object's properties to the source object's properties.
146 | > The target object must deeply match the source object's shape. This means that the property keys must match, and the property values
147 | > must match type. In other words, target objects are not allowed to add or remove properties from source object (when both are plain objects)
148 | > and the property values of target must recursively match the shape or type of the source object's property values.
149 | > Any target property value that does not match it's corresponding source property value does not get assigned.
150 | > Mismatches do not throw errors - the algorithm will default to the source property value and continue to attempt to
151 | > assign any remaining target property values that match. When an actual assignment happens, and dependent observers are queued.
152 | > Arrays are treated similar to plain objects with an important distinction:
153 | > Arrays are allowed to change length. When source is an empty array, we push any items from the target array
154 | > into source because we have no way to compare existing items. When source is an array that has items and target is an array
155 | > that has more items than source, any added items must match the shape or type of the last item in the source array.
156 | > When the target array is shorter than or equal in length to the source array, we patch each item recursively.
157 |
158 | ##### reset([key])
159 | Resets a state object to the data passed with the initial model definition (i.e the first object creation)
160 |
161 | ##### clone([data])
162 | Creates a new instance of the object model and immediately assigns the passed data. Data must match the model's shape.
163 |
164 | ```javascript
165 | // use "State" as a model to instantiate
166 | // a new person by passing in new props. note
167 | // that we're not providing computed properties or
168 | // nested reactive objects - only plain serializable snapshots
169 | const User = State.clone({
170 | firstName: 'Paul',
171 | lastName: 'Anderson',
172 | nickName: 'PTA',
173 | friends: [
174 | State.snapshot(), // create a snapshot of the first person
175 | {firstName: 'Snoop', lastName: 'Dog', nickName: 'Lion'}
176 | ]
177 | });
178 | ```
179 |
180 | ##### observe(key, callback, [options = {cancelable: false, throttle: 0 / defer: 0}])
181 | Registers a callback function that is fired when the observed keys' value changed.
182 | The callback receives the current value as the only argument.
183 | Observers fire once immediately after registration.
184 | When throttle or defer > 0 the observer is throttled or deferred to the provided interval in milliseconds.
185 | Returns unobserve function when ```options.cancelable = true```, undefined otherwise.
186 |
187 |
188 | ```javascript
189 | State.observe('greeting', value => {
190 | // fires whenever greeting computes a new value
191 | // and once, immediately after registration
192 | });
193 |
194 | const unobserve = State.observe('*', value => {
195 | // wildcard listener fires when any
196 | // public property (see "Private Properties")
197 | // in the object has changed, including propagated
198 | // changes from nested reactive objects.
199 | }, {
200 | cancelable: true, // make observe() return an unobserve function
201 | throttle: 250 // execute no more than once every 250ms
202 | });
203 | ```
204 | > It is theoretically possible that throttled or deferred observers may fire
205 | the same state value they fired previously. Consider this scenario:
206 | (1) Observed state changes to a new value.
207 | (2) Sekoia queues the state observer.
208 | (3) The observer is waiting for it's defer/throttle timeout to pass.
209 | (4) The state value changes back to the initial value while the observer is waiting.
210 | (5) The observer fires with the same value it fired previously.
211 |
212 | ##### bind(key)
213 | Returns a binding which can be implemented as a property value on other objects. Source property must
214 | be public. Target property must be public unless it is bound to a [readonly computed property](#-computed-properties).
215 | ```javascript
216 | // The "source of truth" object
217 | const StateA = new ReactiveObject({
218 | foo: 123,
219 | _baz: 456,
220 | uff: ({foo, _baz}) => foo + baz
221 | });
222 |
223 | // The object bound to source object
224 | const StateB = new ReactiveObject({
225 | bar: StateA.bind('foo'),
226 | boo: StateA.bind('_baz'), // -> THROWS. Do not bind private properties. See "Private Properties"
227 | _baz: StateA.bind('foo'), // -> THROWS. Do not bind private properties to writable properties.
228 | _ok: StateA.bind('uff') // -> Works because "uff" is readonly (and public)
229 | });
230 |
231 | ```
232 |
233 | ##### track(key, [options = {maxEntries: 100, onTrack: fn, throttle: 0 / defer: 0}])
234 | Record all state changes for time-travel. Records entire object when
235 | key is wildcard.
236 |
237 | ```options.maxEntries``` determines how many state changes
238 | are recorded before old state changes get removed from the beginning of the
239 | internal history track.
240 | ```options.onTrack(𝑓)``` is a callback function that is invoked
241 | whenever you time-travel to a tracked state.
242 |
243 | ```javascript
244 | State.track('prop', {
245 | maxEntries: 500,
246 | throttle: 250, // EITHER: track no more than once every 250ms
247 | defer: 250, // OR: track 250ms after the last state change has occured
248 | onTrack: (state, trackPosition, trackLength) => {
249 | // update the ui or something
250 | }
251 | })
252 | ```
253 | > Recorded states are guaranteed to be unique - even when asynchronously throttled or deferred.
254 |
255 | ##### undo(key)
256 | Time travel to the last tracked state change. Requires that object or key is being tracked.
257 |
258 | ##### redo(key)
259 | Time travel to next tracked state change. Requires that object or key is being tracked.
260 |
261 | ##### restore(key, trackPosition)
262 | Time travel to specified trackPosition. trackPosition is an index in the internal state
263 | history array.
264 |
265 | ## ReactiveArray Specialties
266 | ReactiveArrays have some important distinctions from ReactiveObjects:
267 |
268 | - Most obviously, ReactiveArrays can change lengths, or, in other words, as opposed to ReactiveObjects, their property keys (indices) are allowed to change.
269 | - Individual indices cannot be observed. ```ReactiveArray.observe(handler => {})``` does not receive a "key" argument. All observers are wildcard observers that react to anything happening within the array.
270 | - ReactiveArray can be instantiated with a model creation function that transforms any data that is added to the array at runtime.
271 | - ReactiveArrays have no computed properties, no bindings and no private keys.
272 |
273 | In addition to the methods from ReactiveObject, ReactiveArray also implements reactive versions of all methods from Array.prototype
274 |
275 | ##### Accessors and Iterators
276 |
277 | - every
278 | - some
279 | - findIndex
280 | - findLastIndex
281 | - includes
282 | - indexOf
283 | - lastIndexOf
284 | - find
285 | - slice
286 | - concat
287 | - forEach
288 | - map
289 | - filter
290 | - reduce
291 |
292 | ##### Mutators
293 |
294 | - pop
295 | - push
296 | - shift
297 | - unshift
298 | - splice
299 | - reverse
300 | - sort
301 | - filterInPlace (just like filter() but mutating)
302 | - concatInPlace(array, prepend = false) -> add array contents to end or beginning
303 | - clear (removes all items from array)
304 |
305 | > When ReactiveArrays contain nested ReactiveObjects, the object patching rules described above apply for all mutators as well.
306 |
307 | ***
308 |
309 | #### 💾 Persistence
310 | Sekoia provides a simple Promise-based IndexedDB abstraction for client-side data persistence.
311 |
312 | ```javascript
313 | const storage = new PeristentStorage({
314 | name: 'userData',
315 | onUnavailable: error => {
316 | // will fall-back to in-memory storage.
317 | alert('Failed to save data to disk. Your data will be gone when you reload the page.')
318 | }
319 | });
320 |
321 | storage.set('key', anything).then(() => {
322 | console.log('stored successfully');
323 | });
324 |
325 | storage.has('key').then(exists => {
326 | console.log('key exist ===', exists);
327 | });
328 |
329 | storage.get('key').then(value);
330 |
331 | storage.delete('key').then(itsDeleted);
332 | storage.clear().then(itsEmpty);
333 | storage.destroy().then(itsGone);
334 | ```
335 |
336 |
337 | ### License
338 | ```
339 | Sekoia.js
340 | Copyright (C) 2022 Jonathan M. Ochmann
341 |
342 | This program is free software: you can redistribute it and/or modify
343 | it under the terms of the GNU General Public License as published by
344 | the Free Software Foundation, either version 3 of the License, or
345 | (at your option) any later version.
346 |
347 | This program is distributed in the hope that it will be useful,
348 | but WITHOUT ANY WARRANTY; without even the implied warranty of
349 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
350 | GNU General Public License for more details.
351 |
352 | You should have received a copy of the GNU General Public License
353 | along with this program. If not, see https://www.gnu.org/licenses.
354 | ```
355 | ***
356 | ### Author
357 | Jonathan M. Ochmann (@monokee)
358 |