From 1e44d991b0092a85b05745cf3456731144702772 Mon Sep 17 00:00:00 2001 From: Cyrille Pontvieux Date: Sun, 25 Jun 2023 20:48:03 +0200 Subject: [PATCH 1/4] Onefile example --- microscope/app.mjs | 11 +++++-- microscope/component.mjs | 4 ++- microscope/dom-diff-applier.mjs | 20 +++++++++--- microscope/index.mjs | 12 +++++-- microscope/vdom-renderer.mjs | 3 ++ onefile.html | 57 +++++++++++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 onefile.html diff --git a/microscope/app.mjs b/microscope/app.mjs index 5cf81c9..2fe0da1 100644 --- a/microscope/app.mjs +++ b/microscope/app.mjs @@ -3,11 +3,18 @@ import { Component } from './component.mjs' class App extends Component { mount(querySelector) { if (!this.template) { - this.template = querySelector - this._createRenderVDom() + const domTemplate = querySelector.nodeType ? querySelector : document.querySelector(querySelector) + if (domTemplate.querySelector('template')) { + const template = domTemplate.querySelector('template') + document.body.appendChild(template) + this.template = template.content + } else { + this.template = querySelector + } } super.mount(querySelector) this.render() + return this } } diff --git a/microscope/component.mjs b/microscope/component.mjs index 61d0669..3c95e47 100644 --- a/microscope/component.mjs +++ b/microscope/component.mjs @@ -31,7 +31,7 @@ class Component { _parseTemplate() { if (this.template) { let domEl = null - if (this.template.toString() === this.template) { + if (typeof this.template == "string") { const tpl = document.querySelector(this.template) domEl = tpl?.content ?? tpl // use shadow dom or direct component } else { @@ -67,6 +67,7 @@ class Component { new DomDiffApplier(this._parentEl, this._indexEl).apply(this._previousVDom, vDom) this._previousVDom = vDom } + return this } mount(domOrQuery) { @@ -75,6 +76,7 @@ class Component { const domEl = domOrQuery.nodeType ? domOrQuery : document.querySelector(domOrQuery) this.attachToDom(domEl) } + return this } } diff --git a/microscope/dom-diff-applier.mjs b/microscope/dom-diff-applier.mjs index 1e1d97f..4319fcb 100644 --- a/microscope/dom-diff-applier.mjs +++ b/microscope/dom-diff-applier.mjs @@ -50,11 +50,22 @@ class DomDiffApplier { _applyVDomDiff(parentEl, index, prevVdom, newVDom) { const currentNode = parentEl.childNodes[index] if (!newVDom) { // replace old node with a comment to keep index - this._replaceOrAppendChild(parentEl, currentNode, this._createDummyChild()) + if (Array.isArray(prevVdom)) { + prevVdom.forEach(prevVdomItem => this._applyVDomDiff(parentEl, index, prevVdomItem, null)) + } else { + this._replaceOrAppendChild(parentEl, currentNode, this._createDummyChild()) + } } else if (!prevVdom) { // first render - this._replaceOrAppendChild(parentEl, currentNode, this._createChildElement(newVDom)) - } else if (Array.isArray(prevVdom) != Array.isArray(newVDom)) { - // TODO + if (Array.isArray(newVDom)) { + newVDom.forEach(newVDomItem => this._applyVDomDiff(parentEl, index, null, newVDomItem)) + } else { + this._replaceOrAppendChild(parentEl, currentNode, this._createChildElement(newVDom)) + } + } else if (Array.isArray(prevVdom) && Array.isArray(newVDom)) { + const maxVdom = Math.max(prevVdom.length, newVDom.length) + for (let i = 0; i < maxVdom; i++) { + this._applyVDomDiff(parentEl, index, prevVdom[i] ?? null, newVDom[i] ?? null) + } } else if (prevVdom.type !== newVDom.type) { this._replaceOrAppendChild(parentEl, currentNode, this._createChildElement(newVDom)) } else { @@ -116,7 +127,6 @@ class DomDiffApplier { } apply(prevVdom, newVDom) { - console.log(newVDom) this._applyVDomDiff(this.parentEl, this.index, prevVdom, newVDom) } } diff --git a/microscope/index.mjs b/microscope/index.mjs index 77e853a..47b2219 100644 --- a/microscope/index.mjs +++ b/microscope/index.mjs @@ -4,8 +4,16 @@ import { App } from './app.mjs' function defineComponent(comp) { return new Component(comp) } -function createApp(comp) { - return new App(comp) +function createApp(comp, mountPoint) { + if (typeof comp == "function") { + // assume setup + comp = { name: 'App', setup: comp } + } + const app = new App(comp) + if (mountPoint) { + app.mount(mountPoint) + } + return app } function createTemplate(templateStr) { return new DOMParser().parseFromString(templateStr.trim(), 'text/html').body diff --git a/microscope/vdom-renderer.mjs b/microscope/vdom-renderer.mjs index 8990c80..6e57afd 100644 --- a/microscope/vdom-renderer.mjs +++ b/microscope/vdom-renderer.mjs @@ -95,6 +95,9 @@ class VDomRenderer { } break } + if (!name) { + break + } } if (name) { const children = childAndStates.map( diff --git a/onefile.html b/onefile.html new file mode 100644 index 0000000..0b5f377 --- /dev/null +++ b/onefile.html @@ -0,0 +1,57 @@ + + + + + Microscope - micro reactive components + + + +
+ +
+ + + -- GitLab From 3880b7403b4fa5131e7547f55abe95d394f88311 Mon Sep 17 00:00:00 2001 From: Cyrille Pontvieux Date: Mon, 26 Jun 2023 01:28:50 +0200 Subject: [PATCH 2/4] Reactive and Ref functions --- microscope/reactive.mjs | 232 ++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test-reactive.mjs | 197 ++++++++++++++++++++++++++++++++++ test.mjs | 99 +++++++++++++++++ 4 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 microscope/reactive.mjs create mode 100644 test-reactive.mjs create mode 100644 test.mjs diff --git a/microscope/reactive.mjs b/microscope/reactive.mjs new file mode 100644 index 0000000..0337322 --- /dev/null +++ b/microscope/reactive.mjs @@ -0,0 +1,232 @@ +class DefaultMap extends Map { + constructor(def, ...args) { + super(...args) + this._def = def + } + get(key) { + const value = super.get(key) + if (value === undefined && this._def) { + const def = typeof this._def == 'function' ? this._def() : this._def + this.set(key, def) + return def + } else { + return value + } + } +} +const listeners = new DefaultMap(() => new DefaultMap(() => [])) + +function listen(obj, key, handler) { + const rawObj = isReactive(obj) ? obj._target : obj + listeners.get(rawObj).get(key).push(handler) +} + +let trackReactiveAccess = false +let activeEffect = null + +function isReactive(obj) { + return !!obj?._reactive +} +function isObject(obj) { + return obj && typeof obj === 'object' +} +function isReadonlyObj(obj) { + return obj && (typeof obj === 'bigint' || [Promise, RegExp, WeakRef].some(ObjType => obj instanceof ObjType)) +} +function isDate(obj) { + return obj && obj instanceof Date +} +function isCollection(obj) { + return obj && [Array, WeakMap, Map, WeakSet, Set].some(ObjType => obj instanceof ObjType) +} +function isPrimitive(obj) { + return obj !== undefined && obj != null && ['number', 'boolean', 'string'].includes(typeof obj) +} +function isAssignable(obj) { + return obj == null || obj === undefined || isObject(obj) || isReadonlyObj(obj) || isDate(obj) || isCollection(obj) || isPrimitive(obj) +} +function ensureReactive(obj, key, whiteListKeys, value) { + if (whiteListKeys.includes(key) || isReactive(value)) { + return value + } else if (isObject(value) && !isReadonlyObj(value)) { + const reactiveValue = reactive(value) + obj[key] = reactiveValue + return reactiveValue + } else { + return value + } +} +function doSideEffects(obj, key, oldValue, newValue) { + if (listeners.has(obj) && listeners.get(obj).has(key)) { + listeners.get(obj).get(key).forEach(listener => listener(newValue, oldValue)) + } else { + console.log(key, "changed from", oldValue, "to", newValue, "in", obj) + } +} + +const reservedKeys = ['_reactive', '_target'] +const objHandler = whiteListKeys => ({ + get(obj, key) { + const value = obj[key] + if (!reservedKeys.includes(key) && activeEffect && trackReactiveAccess) { + activeEffect.addDep(obj, key) + } + return ensureReactive(obj, key, whiteListKeys, value) + }, + set(obj, key, newValue) { + if (reservedKeys.includes(key)) { + return true + } + const oldValue = obj[key] + obj[key] = newValue + if (!reservedKeys.includes(key)) { + if (activeEffect && trackReactiveAccess) { + activeEffect.addDep(obj, key) + } + if (isAssignable(newValue)) { + doSideEffects(obj, key, oldValue, newValue) + } + } + return true + }, +}) +const dateHandler = () => ({ + get(obj, key) { + const value = obj[key] + if (typeof value == 'function') { + const func = value.bind(obj) + if (key.startsWith('set')) { + const setterWrapper = function (...args) { + const oldValue = new Date(this) + func(...args) + const newValue = new Date(this) + doSideEffects(this, '', oldValue, newValue) + }.bind(obj) + return setterWrapper + } else { + return func + } + } else { + return value + } + }, +}) +const collHandler = objHandler + +function createReactive(target, handler) { + const proxy = new Proxy(target, handler) + Object.defineProperty(proxy, '_reactive', { + enumerable: false, + configurable: false, + writable: false, + value: true, + }) + Object.defineProperty(proxy, '_target', { + enumerable: false, + configurable: false, + writable: false, + value: target, + }) + return proxy +} +function reactiveObj(obj) { + return createReactive({ ...obj }, objHandler([])) +} +function reactiveDate(date) { + return createReactive(date, dateHandler()) +} +function reactiveCollection(coll) { + return createReactive({ ...coll }, collHandler([])) +} +function reactiveRef(value) { + return createReactive({ value }, objHandler(['value'])) +} +/** + * It takes a JavaScript object as argument and returns Proxy-based reactive copy of the object. + */ +function reactive(obj) { + if (isObject(obj)) { + if (isCollection(obj)) { + return reactiveCollection(obj) + } else if (isDate(obj)) { + return reactiveDate(obj) + } else { + return reactiveObj(obj) + } + } else { + return obj + } +} +/** + * It takes a primitive as argument and returns a reactive mutable object. + * The object has single property ‘value’ and it will point to the primitive argument. + */ +function ref(value) { + return reactiveRef(value) +} + +function setActiveEffect(newValue) { + const oldValue = activeEffect + activeEffect = newValue + return oldValue +} +function setTrackReactiveAccess(newValue) { + const oldValue = trackReactiveAccess + trackReactiveAccess = newValue + return oldValue +} + +class ReactiveEffect { + constructor(func) { + this.func = func + this.deps = new Map() + } + addDep(obj, key) { + let objDeps = this.deps.get(obj) + if (objDeps === undefined) { + this.deps.set(obj, (objDeps = new Set())) + } + objDeps.add(key) + } + run() { + const oldActiveEffect = setActiveEffect(this) + const oldTrackReactiveAccess = setTrackReactiveAccess(true) + this.func() + setActiveEffect(oldActiveEffect) + setTrackReactiveAccess(oldTrackReactiveAccess) + this.deps.forEach((keyset, obj) => { + keyset.forEach(key => { + console.log("adding listener on", obj, ".", key) + listen(obj, key, () => { + console.log(obj, '.', key, 'modified ⇒ update') + this.func() + }) + if (isReactive(obj[key])) { + console.log(`obj.${key} is Reactive, we should probably add it to the listen group`) + console.log("adding listener on", obj[key]) + listen(obj[key], '', () => { + console.log(obj[key], 'modified ⇒ update') + this.func() + }) + } + }) + }) + return this + } +} + +function effect(func) { + if (func && typeof func == 'function') { + const reactiveEffect = new ReactiveEffect(func) + return reactiveEffect.run() + } +} + +export { + reactive, + ref, + isReactive, + listen, + listeners, + effect, +} diff --git a/package.json b/package.json index 3660cf6..ee8fff1 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "create-min": "rollup -i microscope/index.mjs -o microscope.min.mjs -f es -p uglify -m --silent", "gitlab-copy": "mkdir -p public && cp *.html *.mjs *.ico public/ && mv *.min.* public/", "gitlab-usemin": "find public -name '*.mjs' -exec sed -ri '1s|/index|.min|' '{}' ';'", - "gitlab-pages": "yarn run create-min && yarn run gitlab-copy && yarn run gitlab-usemin" + "gitlab-pages": "yarn run create-min && yarn run gitlab-copy && yarn run gitlab-usemin", + "tests": "sh -ec 'for f in test-*.mjs; do node $f $@; done' --" }, "devDependencies": { "rollup": "^3.25.1", diff --git a/test-reactive.mjs b/test-reactive.mjs new file mode 100644 index 0000000..16d6413 --- /dev/null +++ b/test-reactive.mjs @@ -0,0 +1,197 @@ +import { reactive, ref, isReactive, listen, listeners, effect } from './microscope/reactive.mjs' +import { + assertEq, + assertIn, + assertIs, + assertIsNot, + assertTrue, + assertFalse, + runTests, +} from './test.mjs' +runTests({ + test_listen() { + const r = ref(4) + assertTrue(() => isReactive(r)) + const newValues = [] + listen(r, 'value', newValue => newValues.push(newValue)) + assertTrue(() => listeners.has(r._target)) + r.value = 5 + assertEq(newValues, [5]) + }, + test_ref_number() { + const r = ref(4) + assertTrue(() => isReactive(r)) + const newValues = [] + listen(r, 'value', newValue => newValues.push(newValue)) + assertIn('value', r) + assertEq(r.value, 4) + r.value += 2 + assertEq(newValues, [6]) + assertEq(r.value , 6) + }, + test_ref_string() { + const r = ref("test") + assertTrue(() => isReactive(r)) + const newValues = [] + listen(r, 'value', newValue => newValues.push(newValue)) + assertIn('value', r) + assertEq(r.value, "test") + r.value = r.value.toUpperCase() + assertEq(newValues, ["TEST"]) + assertEq(r.value , "TEST") + }, + test_ref_obj() { + const o = { + foo: "bar", + baz: "toto", + } + const r = ref(o) + assertTrue(() => isReactive(r)) + const newValues = [] + listen(r, 'value', newValue => newValues.push(newValue)) + assertIsNot(r, o) + assertIn('value', r) + assertIs(r.value, o) + assertEq(r.value, o) + r.value.baz = "tata" + assertEq(newValues, []) + assertEq(r.value, { foo: "bar", baz: "tata"}) + assertEq(o.baz, "tata") + r.value = { another: "object" } + assertEq(newValues, [{ another: "object" }]) + assertEq(o, { foo: "bar", baz: "tata" }) + }, + test_reactive_simple() { + const o = { + foo: "bar", + baz: "toto", + } + const r = reactive(o) + assertTrue(() => isReactive(r)) + const changes = [] + Object.keys(o).forEach(prop => { + listen(r, prop, value => changes.push([prop, value])) + }) + assertIsNot(r, o) + assertIn('foo', r) + assertIn('baz', r) + assertEq(r.foo, "bar") + assertEq(r.baz, "toto") + assertEq(changes, []) + r.baz = "tata" + assertEq(changes, [['baz', "tata"]]) + assertEq(r.baz, "tata") + assertEq(o.baz, "toto") + }, + test_reactive_inner() { + const r1 = reactive({ + foo: "bar", + baz: { inner: "toto" }, + test: null, + }) + assertTrue(() => isReactive(r1)) + const r1Changes = [] + Object.keys(r1).forEach(prop => { + listen(r1, prop, value => r1Changes.push([prop, value])) + }) + listen(r1.baz, 'inner', value => r1Changes.push(['baz.inner', value])) + const r2 = reactive({ + val: 42, + get isTest() { return true }, + increase() { this.val += 1 }, + }) + const r2Changes = [] + Object.keys(r2).forEach(prop => { + listen(r2, prop, value => r2Changes.push([prop, value])) + }) + assertTrue(() => isReactive(r2)) + assertEq(r1.baz, { inner: "toto" }) + assertEq(r1.test, null) + assertTrue(() => isReactive(r1.baz)) + assertFalse(() => isReactive(r1.test)) + r1.test = r2 + assertTrue(() => isReactive(r1.test)) + assertEq(r1Changes, [['test', r2]]) + assertEq(r2Changes, []) + r1.baz.inner = "tutu" + assertEq(r1.test.isTest, true) + r1.test.val *= 2 + r1.test.increase() + assertEq(r1Changes, [['test', r2], ['baz.inner', "tutu"]]) + assertEq(r2Changes, [['val', 84], ['val', 85]]) + }, + test_reactive_bigint() { + const r = reactive({ + b: BigInt(42), + doubleValue() { + this.b *= BigInt(2) + }, + }) + const changes = [] + Object.keys(r).forEach(prop => { + listen(r, prop, value => changes.push([prop, value])) + }) + r.doubleValue() + assertEq(changes, [['b', BigInt(84)]]) + assertEq(r.b, BigInt(84)) + }, + test_reactive_date() { + const r = reactive({ + d: new Date(Date.UTC(2023, 0, 1)), + addDay() { + this.d.setDate(this.d.getDate() + 1) + }, + }) + const changes = [] + Object.keys(r).forEach(prop => { + listen(r, prop, value => changes.push([prop, value])) + }) + const dateChanges = [] + listen(r.d, '', value => dateChanges.push(value)) + r.addDay() + assertEq(changes, []) // date has not been changed on r + assertEq(dateChanges, [new Date(Date.UTC(2023, 0, 2))]) + assertEq(r.d, new Date(Date.UTC(2023, 0, 2))) + }, + test_reactive_promise() { + // TODO + }, + test_reactive_regexp() { + // TODO + }, + test_reactive_weakref() { + // TODO + }, + test_reactive_array() { + // TODO + }, + test_reactive_map() { + // TODO + }, + test_reactive_set() { + // TODO + }, + test_effect() { + const r = reactive({ + v: "microscope", + b: BigInt(42), + d: new Date(Date.UTC(2023, 0, 1)), + r: new RegExp('^micro'), + a: [{ x: 1, y: 2 }, { x: 2, y: 3 }], + m: { get: () => ({ lat: 43, lon: 4 }) }, // TODO new Map([['home', { lat: 43, lon: 4 }]]), + }) + const results = [] + const e = effect(() => { + if (r.r.test(r.v)) { + const position = r.m.get('home') + results.push(position.lat - Number(r.b) + r.a[1].y + r.a[1].y + r.d.getDate()) + } else { + results.push("should not happen") + } + }) + console.log(e.deps) + assertEq(results, [8]) + r.d.setDate(4) + assertEq(results, [8, 11]) + }, +}) diff --git a/test.mjs b/test.mjs new file mode 100644 index 0000000..2f38269 --- /dev/null +++ b/test.mjs @@ -0,0 +1,99 @@ +function operandToString(op) { + if (typeof op == 'bigint') { + return op.toString() + } else if (typeof op == 'string') { + return op + } else if (Array.isArray(op)) { + return '[' + op.map(item => operandToString(item)).join(',') + ']' + } else { + return JSON.stringify(op, (key, value) => { + if (typeof value == 'bigint') { + return operandToString(value) + } else if (Array.isArray(value)) { + return operandToString(value) + } else { + return value + } + }) + } +} +class AssertionError extends Error { + constructor(a, b, operator) { + const strA = operandToString(a) + const strB = operandToString(b) + super(`${strA} ${operator} ${strB}`) + this.a = a + this.b = b + this.operator = operator + } +} +function assert(testFunc, assertionError) { + if (!testFunc()) { + throw assertionError + } +} +const assertIs = (a, b) => assert(() => a === b, new AssertionError(a, b, 'is')) +const assertIsNot = (a, b) => assert(() => a !== b, new AssertionError(a, b, 'is not')) +const assertEq = (a, b) => assert(() => operandToString(a) == operandToString(b), new AssertionError(a, b, '=')) +const assertNotEq = (a, b) => assert(() => operandToString(a) != operandToString(b), new AssertionError(a, b, '≠')) +const assertIn = (a, b) => assert(() => a in b, new AssertionError(a, b, 'in')) +const assertNotIn = (a, b) => assert(() => !(a in b), new AssertionError(a, b, 'not in')) +const assertTrue = (lambda) => assert(lambda, new AssertionError('', '', lambda.toString())) +const assertFalse = (lambda) => assert(() => !lambda(), new AssertionError('', '', lambda.toString())) +const ANSI_ESC='\x1B' +const ANSI_RESET=`${ANSI_ESC}[0m` +const ANSI_RED=`${ANSI_ESC}[31m` +const ANSI_GREEN=`${ANSI_ESC}[32m` +function runTests(testObj) { + const args = (() => { + try { // node + return process.argv.slice(2) + } catch(e) { + try { // deno + return Deno.args + } catch(e) { + return [] + } + } + })() + const exit = (() => { + try { // node + return process.exit + } catch(e) { + try { // deno + return Deno.exit + } catch(e) { + return () => {} + } + } + })() + let errors = 0 + Object.keys(testObj).filter( + key => key.startsWith('test_') && typeof testObj[key] == 'function' && (args.length == 0 || args.includes(key)) + ).forEach(name => { + try { + testObj[name]() + console.log(`${name}: ${ANSI_GREEN}ok${ANSI_RESET}`) + } catch (e) { + console.log(`${name}: ${ANSI_RED}fail${ANSI_RESET}`) + console.trace(e) + errors += 1 + } + }) + if (errors) { + exit(1) + } +} +export { + runTests, + AssertionError, + assert, + assertIs, + assertIsNot, + assertEq, + assertNotEq, + assertIn, + assertNotIn, + assertTrue, + assertFalse, +} -- GitLab From f186f560bebb96d60bbcf73751ec6cbe7d9f7aba Mon Sep 17 00:00:00 2001 From: Cyrille Pontvieux Date: Tue, 27 Jun 2023 12:23:14 +0200 Subject: [PATCH 3/4] Promise is now reactive --- microscope/reactive.mjs | 24 ++++++++++++++++++----- test-reactive.mjs | 43 +++++++++++++++++++++++++++++++++++++++-- test.mjs | 4 ++-- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/microscope/reactive.mjs b/microscope/reactive.mjs index 0337322..7c904a9 100644 --- a/microscope/reactive.mjs +++ b/microscope/reactive.mjs @@ -1,12 +1,14 @@ class DefaultMap extends Map { + #def + constructor(def, ...args) { super(...args) - this._def = def + this.#def = def } get(key) { const value = super.get(key) - if (value === undefined && this._def) { - const def = typeof this._def == 'function' ? this._def() : this._def + if (value === undefined && this.#def) { + const def = typeof this.#def == 'function' ? this.#def() : this.#def this.set(key, def) return def } else { @@ -31,11 +33,14 @@ function isObject(obj) { return obj && typeof obj === 'object' } function isReadonlyObj(obj) { - return obj && (typeof obj === 'bigint' || [Promise, RegExp, WeakRef].some(ObjType => obj instanceof ObjType)) + return obj && (typeof obj === 'bigint' || [RegExp, WeakRef].some(ObjType => obj instanceof ObjType)) } function isDate(obj) { return obj && obj instanceof Date } +function isPromise(obj) { + return obj && obj instanceof Promise +} function isCollection(obj) { return obj && [Array, WeakMap, Map, WeakSet, Set].some(ObjType => obj instanceof ObjType) } @@ -114,7 +119,7 @@ const dateHandler = () => ({ const collHandler = objHandler function createReactive(target, handler) { - const proxy = new Proxy(target, handler) + const proxy = handler ? new Proxy(target, handler) : target Object.defineProperty(proxy, '_reactive', { enumerable: false, configurable: false, @@ -135,6 +140,13 @@ function reactiveObj(obj) { function reactiveDate(date) { return createReactive(date, dateHandler()) } +function reactivePromise(promise) { + let reactivePromise + reactivePromise = createReactive(promise.finally(() => { + doSideEffects(reactivePromise, '', null, promise) + }), null) // a proxy is not required + return reactivePromise +} function reactiveCollection(coll) { return createReactive({ ...coll }, collHandler([])) } @@ -150,6 +162,8 @@ function reactive(obj) { return reactiveCollection(obj) } else if (isDate(obj)) { return reactiveDate(obj) + } else if (isPromise(obj)) { + return reactivePromise(obj) } else { return reactiveObj(obj) } diff --git a/test-reactive.mjs b/test-reactive.mjs index 16d6413..270a412 100644 --- a/test-reactive.mjs +++ b/test-reactive.mjs @@ -153,8 +153,47 @@ runTests({ assertEq(dateChanges, [new Date(Date.UTC(2023, 0, 2))]) assertEq(r.d, new Date(Date.UTC(2023, 0, 2))) }, - test_reactive_promise() { - // TODO + async test_reactive_promise() { + let data = null + let promiseResult = null + const fetchPromise = new Promise(resolve => { + let intId + intId = setInterval(() => { + if (data) { + clearInterval(intId) + resolve(data) + } + }, 200) + }) + const testPromise = new Promise(resolve => { + fetchPromise.then(data => { + const res = data.value * 3 + resolve(data.value * 3) + promiseResult = res + }) + }) + const r = reactive({ + p: testPromise, + async setDataValue(value) { + await new Promise(resolve => setTimeout(resolve, 500)) + data = { value } + await new Promise(resolve => setTimeout(resolve, 500)) + }, + }) + const changes = [] + Object.keys(r).forEach(prop => { + listen(r, prop, value => changes.push([prop, value])) + }) + const pChanges = [] + listen(r.p, '', value => { + pChanges.push(value) + }) + assertEq(changes, []) + assertEq(pChanges, []) + await r.setDataValue(5) + assertEq(changes, []) + assertEq(pChanges, [r.p]) + assertEq(promiseResult, 15) }, test_reactive_regexp() { // TODO diff --git a/test.mjs b/test.mjs index 2f38269..ef679d2 100644 --- a/test.mjs +++ b/test.mjs @@ -70,9 +70,9 @@ function runTests(testObj) { let errors = 0 Object.keys(testObj).filter( key => key.startsWith('test_') && typeof testObj[key] == 'function' && (args.length == 0 || args.includes(key)) - ).forEach(name => { + ).forEach(async (name) => { try { - testObj[name]() + await testObj[name]() console.log(`${name}: ${ANSI_GREEN}ok${ANSI_RESET}`) } catch (e) { console.log(`${name}: ${ANSI_RED}fail${ANSI_RESET}`) -- GitLab From 4c803989d6f05b5e2b2b7b0d64ad087f88639787 Mon Sep 17 00:00:00 2001 From: Cyrille Pontvieux Date: Tue, 4 Jul 2023 08:25:30 +0200 Subject: [PATCH 4/4] Reactivity inspired by vue-core --- .gitignore | 1 + .gitlab-ci.yml | 16 +- microscope/explanations.md | 19 + microscope/index.mjs | 1 + microscope/reactive.mjs | 1123 +++++++++++++++++++++++++++++------- package.json | 9 +- test-reactive.mjs | 236 -------- test.mjs | 99 ---- tests/index.mjs | 149 +++++ tests/test_reactive.mjs | 190 ++++++ 10 files changed, 1305 insertions(+), 538 deletions(-) create mode 100644 microscope/explanations.md delete mode 100644 test-reactive.mjs delete mode 100644 test.mjs create mode 100644 tests/index.mjs create mode 100644 tests/test_reactive.mjs diff --git a/.gitignore b/.gitignore index 7fc1370..6530ed1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +dist/ public/ *.min.mjs *.map diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9eb565e..ed1eba4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,19 @@ +stages: + - test + - publish +test: + stage: test + image: node:20 + script: + - yarn install + - yarn run tests pages: - image: jitesoft/node-yarn:20 + stage: publish + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + needs: + - test + image: node:20 script: - yarn install - yarn run gitlab-pages diff --git a/microscope/explanations.md b/microscope/explanations.md new file mode 100644 index 0000000..9829c22 --- /dev/null +++ b/microscope/explanations.md @@ -0,0 +1,19 @@ +Reactivity +========== + +Effect definition +----------------- + +1. effect on lambda +1. active effect is memorized +1. reactivity is marked as monitored +1. lambda is run +1. any access to any reactive variable will be registered on active effect as dependency (track) +1. lambda result is returned + +Effect on change reactivity +--------------------------- + +1. a reactive object is changed +1. search all effects registered on the reactive object (trigger) +1. apply them (first ones registered as computed, then the others) diff --git a/microscope/index.mjs b/microscope/index.mjs index 47b2219..756b168 100644 --- a/microscope/index.mjs +++ b/microscope/index.mjs @@ -1,5 +1,6 @@ import { Component } from './component.mjs' import { App } from './app.mjs' +export * from './reactive.mjs' function defineComponent(comp) { return new Component(comp) diff --git a/microscope/reactive.mjs b/microscope/reactive.mjs index 7c904a9..78ee91a 100644 --- a/microscope/reactive.mjs +++ b/microscope/reactive.mjs @@ -1,246 +1,973 @@ -class DefaultMap extends Map { - #def +/* Inspired by Vuejs Core */ +const hasOwn = (val, key) => Object.prototype.hasOwnProperty.call(val, key) +const toTypeString = val => Object.prototype.toString.call(val) +const isArray = Array.isArray +const isMap = val => toTypeString(val) === '[object Map]' +const isSet = val => toTypeString(val) === '[object Set]' +const isDate = val => toTypeString(val) === '[object Date]' +const isRegExp = val => toTypeString(val) === '[object RegExp]' +const isFunction = val => typeof val === 'function' +const isString = val => typeof val === 'string' +const isSymbol = val => typeof val === 'symbol' +const isObject = val => val !== null && typeof val === 'object' +const isPromise = val => isObject(val) && isFunction(val.then) && isFunction(val.catch) +const toRawType = val => toTypeString(val).slice(8, -1) // "[object RawType]" +const isIntegerKey = key => isString(key) && key !== 'NaN' && key[0] !== '-' && '' + parseInt(key, 10) === key +const hasChanged = (value, oldValue) => !Object.is(value, oldValue) // compare whether a value has changed, accounting for NaN. +const def = (obj, key, value) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + value, + }) +} +const makeEnum = obj => Object.freeze(obj) +const ReactiveFlags = makeEnum({ + SKIP: '__skip', + IS_REACTIVE: '__isReactive', + RAW: '__raw', +}) +const RefFlag = '__isRef' +const ComputedRefSymbol = Symbol('computed ref') +const TargetType = makeEnum({ + INVALID: 0, + COMMON: 1, + COLLECTION: 2, +}) +const TrackOpTypes = makeEnum({ + GET: 'get', + HAS: 'has', + ITERATE: 'iterate', +}) +const TriggerOpTypes = makeEnum({ + SET: 'set', + ADD: 'add', + DELETE: 'delete', + CLEAR: 'clear', +}) +const ITERATE_KEY = Symbol('iterate') +const MAP_KEY_ITERATE_KEY = Symbol('Map key iterate') +// +let activeEffect = undefined +let shouldTrack = true +const trackStack = [] // booleans +const reactiveMap = new WeakMap() // stores reactive proxies for object +const targetMap = new WeakMap() // stores {target -> key -> dep} connections. WeakMap>> +// - constructor(def, ...args) { - super(...args) - this.#def = def - } - get(key) { - const value = super.get(key) - if (value === undefined && this.#def) { - const def = typeof this.#def == 'function' ? this.#def() : this.#def - this.set(key, def) - return def - } else { - return value - } +function targetTypeMap(rawType) { + switch (rawType) { + case 'Object': + case 'Array': + return TargetType.COMMON + case 'Map': + case 'Set': + case 'WeakMap': + case 'WeakSet': + return TargetType.COLLECTION + default: + return TargetType.INVALID } } -const listeners = new DefaultMap(() => new DefaultMap(() => [])) -function listen(obj, key, handler) { - const rawObj = isReactive(obj) ? obj._target : obj - listeners.get(rawObj).get(key).push(handler) +function getTargetType(value) { + return value[ReactiveFlags.SKIP] || !Object.isExtensible(value) ? TargetType.INVALID : targetTypeMap(toRawType(value)) } -let trackReactiveAccess = false -let activeEffect = null +const baseHandlers = (function () { + const nonTrackableKeys = new Set(['__proto__', RefFlag]) + const builtInSymbols = new Set(Object.getOwnPropertyNames(Symbol).filter(key => key != 'arguments' && key != 'caller').map(key => Symbol[key]).filter(isSymbol)) + const isNonTrackableKey = key => isSymbol(key) ? builtInSymbols.has(key) : nonTrackableKeys.has(key) + const arrayInstrumentations = createArrayInstrumentations() + function createArrayInstrumentations() { + const instrumentations = {} + // instrument identity-sensitive Array methods to account for possible reactive values + ;['includes', 'indexOf', 'lastIndexOf'].forEach(key => { + instrumentations[key] = function (...args) { + const arr = toRaw(this) + for (let i = 0, l = this.length; i < l; i++) { + track(arr, TrackOpTypes.GET, i + '') + } + const res = arr[key](...args) // we run the method using the original args first (which may be reactive) + if (res === -1 || res === false) { // if that didn't work, run it again using raw values. + return arr[key](...args.map(toRaw)) + } else { + return res + } + } + }) + // instrument length-altering mutation methods to avoid length being tracked + // which leads to infinite loops in some cases (#2137) + ;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => { + instrumentations[key] = function (...args) { + pauseTracking() + const res = toRaw(this)[key].apply(this, args) + resetTracking() + return res + } + }) + return instrumentations + } + function hasOwnProperty(key) { + const obj = toRaw(this) + track(obj, TrackOpTypes.HAS, key) + return obj.hasOwnProperty(key) + } + const get = createGetter() + function createGetter() { + return function get(target, key, receiver) { + if (key === ReactiveFlags.IS_REACTIVE) { + return true + } else if (key === ReactiveFlags.RAW && receiver === reactiveMap.get(target)) { + return target + } + const targetIsArray = isArray(target) + if (targetIsArray && hasOwn(arrayInstrumentations, key)) { + return Reflect.get(arrayInstrumentations, key, receiver) + } + if (key === 'hasOwnProperty') { + return hasOwnProperty + } + const res = Reflect.get(target, key, receiver) + if (isNonTrackableKey(key)) { + return res + } + track(target, TrackOpTypes.GET, key) + if (isRef(res)) { // ref unwrapping + // skip unwrap for Array + integer key, i.e. arr[4] + return targetIsArray && isIntegerKey(key) ? res : res.value + } + return isObject(res) ? reactive(res) : res + } + } + const set = createSetter() + function createSetter() { + return function set(target, key, value, receiver) { + let oldValue = target[key] + oldValue = toRaw(oldValue) + value = toRaw(value) + if (!isArray(target) && isRef(oldValue) && !isRef(value)) { + oldValue.value = value + return true + } + const hadKey = isArray(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key) + const result = Reflect.set(target, key, value, receiver) + // don't trigger if target is something up in the prototype chain of original + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, TriggerOpTypes.ADD, key, value) + } else if (hasChanged(value, oldValue)) { + trigger(target, TriggerOpTypes.SET, key, value, oldValue) + } + } + return result + } + } + function deleteProperty(target, key) { + const hadKey = hasOwn(target, key) + const oldValue = target[key] + const result = Reflect.deleteProperty(target, key) + if (result && hadKey) { + trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue) + } + return result + } + function has(target, key) { + const result = Reflect.has(target, key) + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, TrackOpTypes.HAS, key) + } + return result + } + function ownKeys(target) { + track(target, TrackOpTypes.ITERATE, isArray(target) ? 'length' : ITERATE_KEY) + return Reflect.ownKeys(target) + } + return { + get, + set, + deleteProperty, + has, + ownKeys + } +})() +const collectionHandlers = (function () { + const getProto = v => Reflect.getPrototypeOf(v) + function get(target, key) { + target = target[ReactiveFlags.RAW] + const rawTarget = toRaw(target) + const rawKey = toRaw(key) + if (key !== rawKey) { + track(rawTarget, TrackOpTypes.GET, key) + } + track(rawTarget, TrackOpTypes.GET, rawKey) + const { has } = getProto(rawTarget) + if (has.call(rawTarget, key)) { + return toReactive(target.get(key)) + } else if (has.call(rawTarget, rawKey)) { + return toReactive(target.get(rawKey)) + } else if (target !== rawTarget) { + target.get(key) // ensure that the nested reactive `Map` can do tracking for itself + } + } + function has(key) { + const target = this[ReactiveFlags.RAW] + const rawTarget = toRaw(target) + const rawKey = toRaw(key) + if (key !== rawKey) { + track(rawTarget, TrackOpTypes.HAS, key) + } + track(rawTarget, TrackOpTypes.HAS, rawKey) + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey) + } + function size(target) { + target = target[ReactiveFlags.RAW] + track(toRaw(target), TrackOpTypes.ITERATE, ITERATE_KEY) + return Reflect.get(target, 'size', target) + } + function add(value) { + value = toRaw(value) + const target = toRaw(this) + const { has } = getProto(target) + if (!has.call(target, value)) { + target.add(value) + trigger(target, TriggerOpTypes.ADD, value, value) + } + return this + } + function set(key, value) { + value = toRaw(value) + const target = toRaw(this) + const { has, get } = getProto(target) + let hadKey = has.call(target, key) + if (!hadKey) { + key = toRaw(key) + hadKey = has.call(target, key) + } + const oldValue = get.call(target, key) + target.set(key, value) + if (!hadKey) { + trigger(target, TriggerOpTypes.ADD, key, value) + } else if (hasChanged(value, oldValue)) { + trigger(target, TriggerOpTypes.SET, key, value, oldValue) + } + return this + } + function deleteEntry(key) { + const target = toRaw(this) + const { has, get } = getProto(target) + let hadKey = has.call(target, key) + if (!hadKey) { + key = toRaw(key) + hadKey = has.call(target, key) + } + const oldValue = get ? get.call(target, key) : undefined + // forward the operation before queueing reactions + const result = target.delete(key) + if (hadKey) { + trigger(target, TriggerOpTypes.DELETE, key, undefined, oldValue) + } + return result + } + function clear() { + const target = toRaw(this) + const hadItems = target.size !== 0 + // forward the operation before queueing reactions + const result = target.clear() + if (hadItems) { + trigger(target, TriggerOpTypes.CLEAR, undefined, undefined, undefined) + } + return result + } + function createForEach() { + return function forEach(callback, thisArg) { + const observed = this + const target = observed[ReactiveFlags.RAW] + const rawTarget = toRaw(target) + track(rawTarget, TrackOpTypes.ITERATE, ITERATE_KEY) + return target.forEach((value, key) => { + // important: make sure the callback is + // 1. invoked with the reactive map as `this` and last arg + // 2. the value received should be reactive. + return callback.call(thisArg, toReactive(value), toReactive(key), observed) + }) + } + } + function createIterableMethod(method) { + return function (...args) { + const target = this[ReactiveFlags.RAW] + const rawTarget = toRaw(target) + const targetIsMap = isMap(rawTarget) + const isPair = method === 'entries' || (method === Symbol.iterator && targetIsMap) + const isKeyOnly = method === 'keys' && targetIsMap + const wrapValue = isPair ? value => value.map(toReactive) : toReactive + const innerIterator = target[method](...args) + track(rawTarget, TrackOpTypes.ITERATE, isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY) + return { // return a wrapped iterator which returns observed versions of the values emitted from the real iterator + next() { // iterator protocol + const { value, done } = innerIterator.next() + return { + value: done ? value : wrapValue(value), + done, + } + }, + [Symbol.iterator]() { // iterable protocol + return this + } + } + } + } + function createInstrumentations() { + const instrumentations = { + get(key) { + return get(this, key) + }, + get size() { + return size(this) + }, + has, + add, + set, + delete: deleteEntry, + clear, + forEach: createForEach(), + } + ;['keys', 'values', 'entries', Symbol.iterator].forEach(method => { + instrumentations[method] = createIterableMethod(method) + }) + return instrumentations + } + const instrumentations = createInstrumentations() + function createInstrumentationGetter() { + return (target, key, receiver) => { + if (key === ReactiveFlags.IS_REACTIVE) { + return true + } else if (key === ReactiveFlags.RAW) { + return target + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver, + ) + } + } + return { + get: createInstrumentationGetter(), + } +})() -function isReactive(obj) { - return !!obj?._reactive +/** + * Returns a reactive proxy of the object. + * + * The reactive conversion is "deep": it affects all nested properties. + * A reactive object also deeply unwraps any properties that are refs while maintaining reactivity. + * + * @example + * ```js + * const obj = reactive({ count: 0 }) + * ``` + * + * @param target - The source object. + */ +export function reactive(target) { + if (!isObject(target)) { + console.warn(`value cannot be made reactive: ${String(target)}`) + return target + } + if (target[ReactiveFlags.RAW]) { // target is already a Proxy, return it. + return target + } + const existingProxy = reactiveMap.get(target) + if (existingProxy) { // target already has corresponding Proxy + return existingProxy + } + const targetType = getTargetType(target) + if (targetType === TargetType.INVALID) { // only specific value types can be observed. + return target + } + const handlers = targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers + const proxy = new Proxy(target, handlers) + reactiveMap.set(target, proxy) + return proxy } -function isObject(obj) { - return obj && typeof obj === 'object' + +/** + * Checks if an object is a proxy created by {@link reactive()} or {@link ref()} in some cases. + * + * @example + * ```js + * isReactive(reactive({})) // => true + * isReactive(ref({}).value) // => true + * isReactive(ref(true)) // => false + * ``` + * + * @param value - The value to check. + */ +export function isReactive(value) { + return !!(value && value[ReactiveFlags.IS_REACTIVE]) } -function isReadonlyObj(obj) { - return obj && (typeof obj === 'bigint' || [RegExp, WeakRef].some(ObjType => obj instanceof ObjType)) + +/** + * Checks if an object is a proxy created by {@link reactive} + * + * @param value - The value to check. + */ +export function isProxy(value) { + return isReactive(value) } -function isDate(obj) { - return obj && obj instanceof Date + +/** + * Returns the raw, original object of a created proxy. + * `toRaw()` can return the original object from proxies created by {@link reactive()}. + * + * This is an escape hatch that can be used to temporarily read without incurring tracking overhead or write without triggering changes. + * It is **not** recommended to hold a persistent reference to the original object. Use with caution. + * + * @example + * ```js + * const foo = {} + * const reactiveFoo = reactive(foo) + * console.log(toRaw(reactiveFoo) === foo) // true + * ``` + * + * @param observed - The object for which the "raw" value is requested. + */ +export function toRaw(observed) { + const raw = observed && observed[ReactiveFlags.RAW] + return raw ? toRaw(raw) : observed } -function isPromise(obj) { - return obj && obj instanceof Promise + +/** + * Marks an object so that it will never be converted to a proxy. + * Returns the object itself. + * + * @example + * ```js + * const foo = markRaw({}) + * console.log(isReactive(reactive(foo))) // false + * // also works when nested inside other reactive objects + * const bar = reactive({ foo }) + * console.log(isReactive(bar.foo)) // false + * ``` + * + * @param value - The object to be marked as "raw". + */ +export function markRaw(value) { + def(value, ReactiveFlags.SKIP, true) + return value } -function isCollection(obj) { - return obj && [Array, WeakMap, Map, WeakSet, Set].some(ObjType => obj instanceof ObjType) + +/** + * Returns a reactive proxy of the given value (if possible). + * If the given value is not an object, the original value itself is returned. + * + * @param value - The value for which a reactive proxy shall be created. + */ +export const toReactive = value => isObject(value) ? reactive(value) : value + +const createDep = effects => new Set(effects) +function trackRefValue(ref) { + if (shouldTrack && activeEffect) { + ref = toRaw(ref) + trackEffects(ref.dep || (ref.dep = createDep())) + } } -function isPrimitive(obj) { - return obj !== undefined && obj != null && ['number', 'boolean', 'string'].includes(typeof obj) +function triggerRefValue(ref) { + ref = toRaw(ref) + const dep = ref.dep + if (dep) { + triggerEffects(dep) + } } -function isAssignable(obj) { - return obj == null || obj === undefined || isObject(obj) || isReadonlyObj(obj) || isDate(obj) || isCollection(obj) || isPrimitive(obj) +/** + * Checks if a value is a ref object. + * + * @param value - The value to inspect. + */ +export function isRef(value) { + return !!(value && value[RefFlag] === true) } -function ensureReactive(obj, key, whiteListKeys, value) { - if (whiteListKeys.includes(key) || isReactive(value)) { - return value - } else if (isObject(value) && !isReadonlyObj(value)) { - const reactiveValue = reactive(value) - obj[key] = reactiveValue - return reactiveValue - } else { +/** + * Takes an inner value and returns a reactive and mutable ref object, + * which has a single property `.value` that points to the inner value. + * + * @param value - The object to wrap in the ref. + */ +export function ref(value) { + if (isRef(value)) { return value } + return new RefImpl(value) +} +class RefImpl { + #rawValue + #value + dep = undefined // Set | undefined + constructor(value) { + this.#rawValue = toRaw(value) + this.#value = toReactive(value) + } + get [RefFlag]() { return true } + get value() { + trackRefValue(this) + return this.#value + } + set value(newVal) { + newVal = toRaw(newVal) + if (hasChanged(newVal, this.#rawValue)) { + this.#rawValue = newVal + this.#value = toReactive(newVal) + triggerRefValue(this) + } + } } -function doSideEffects(obj, key, oldValue, newValue) { - if (listeners.has(obj) && listeners.get(obj).has(key)) { - listeners.get(obj).get(key).forEach(listener => listener(newValue, oldValue)) +/** + * Returns the inner value if the argument is a ref, otherwise return the + * argument itself. This is a sugar function for + * `val = isRef(val) ? val.value : val`. + * + * @example + * ```js + * function useFoo(x: number | Ref) { + * const unwrapped = unref(x) + * // unwrapped is guaranteed to be number now + * } + * ``` + * + * @param ref - Ref or plain value to be converted into the plain value. + */ +export function unref(ref) { + return isRef(ref) ? ref.value : ref +} +/** + * Normalizes values / refs / getters to values. + * This is similar to {@link unref()}, except that it also normalizes getters. + * If the argument is a getter, it will be invoked and its return value will + * be returned. + * + * @example + * ```js + * toValue(1) // 1 + * toValue(ref(1)) // 1 + * toValue(() => 1) // 1 + * ``` + * + * @param source - A getter, an existing ref, or a non-function value. + */ +export function toValue(source) { + return isFunction(source) ? source() : unref(source) +} +/** + * Converts a reactive object to a plain object where each property of the + * resulting object is a ref pointing to the corresponding property of the + * original object. Each individual ref is created using {@link toRef()}. + * + * @param object - Reactive object to be made into an object of linked refs. + */ +export function toRefs(object) { + if (!isProxy(object)) { + console.warn(`toRefs() expects a reactive object but received a plain one.`) + } + const ret = isArray(object) ? new Array(object.length) : {} + for (const key in object) { + ret[key] = propertyToRef(object, key) + } + return ret +} +class ObjectRefImpl { + #object + #key + #defaultValue + constructor(object, key, defaultValue) { + this.#object = object + this.#key = key + this.#defaultValue = defaultValue // could be undefined + } + get [RefFlag]() { return true } + get value() { + const val = this.#object[this.#key] + return val === undefined ? this.#defaultValue : val + } + set value(newVal) { + this.#object[this.#key] = newVal + } + get dep() { + return this.getDepFromReactive(toRaw(this.#object), this.#key) + } + getDepFromReactive(object, key) { + return targetMap.get(object)?.get(key) + } +} +class GetterRefImpl { + #getter + constructor(getter) { + this.#getter = getter + } + get [RefFlag]() { return true } + get value() { + return this.#getter() + } +} +/** + * Used to normalize values / refs / getters into refs. + * + * @example + * ```js + * // returns existing refs as-is + * toRef(existingRef) + * // creates a ref that calls the getter on .value access + * toRef(() => props.foo) + * // creates normal refs from non-function values, equivalent to ref(1) + * toRef(1) + * ``` + * + * Can also be used to create a ref for a property on a source reactive object. + * The created ref is synced with its source property: mutating the source + * property will update the ref, and vice-versa. + * + * @example + * ```js + * const state = reactive({ + * foo: 1, + * bar: 2 + * }) + * const fooRef = toRef(state, 'foo') + * // mutating the ref updates the original + * fooRef.value++ + * console.log(state.foo) // 2 + * // mutating the original also updates the ref + * state.foo++ + * console.log(fooRef.value) // 3 + * ``` + * + * @param source - A getter, an existing ref, a non-function value, or a reactive object to create a property ref from. + * @param [key] - (optional) Name of the property in the reactive object. + * @param [defaultValue] - (optional) default value for reactive property. + */ +export function toRef(source, key, defaultValue) { + if (isRef(source)) { + return source + } else if (isFunction(source)) { + return new GetterRefImpl(source) + } else if (isObject(source) && arguments.length > 1) { + return propertyToRef(source, key, defaultValue) // defaultValue could still be undefined } else { - console.log(key, "changed from", oldValue, "to", newValue, "in", obj) + return ref(source) } } +function propertyToRef(source, key, defaultValue) { + const val = source[key] + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue) +} -const reservedKeys = ['_reactive', '_target'] -const objHandler = whiteListKeys => ({ - get(obj, key) { - const value = obj[key] - if (!reservedKeys.includes(key) && activeEffect && trackReactiveAccess) { - activeEffect.addDep(obj, key) - } - return ensureReactive(obj, key, whiteListKeys, value) - }, - set(obj, key, newValue) { - if (reservedKeys.includes(key)) { - return true - } - const oldValue = obj[key] - obj[key] = newValue - if (!reservedKeys.includes(key)) { - if (activeEffect && trackReactiveAccess) { - activeEffect.addDep(obj, key) - } - if (isAssignable(newValue)) { - doSideEffects(obj, key, oldValue, newValue) - } - } - return true - }, -}) -const dateHandler = () => ({ - get(obj, key) { - const value = obj[key] - if (typeof value == 'function') { - const func = value.bind(obj) - if (key.startsWith('set')) { - const setterWrapper = function (...args) { - const oldValue = new Date(this) - func(...args) - const newValue = new Date(this) - doSideEffects(this, '', oldValue, newValue) - }.bind(obj) - return setterWrapper - } else { - return func +class ReactiveEffect { + fn + scheduler = null // for computed function + active = true + deps = [] // Array> + parent = undefined // or ReactiveEffect + computed = undefined // Can be attached after creation, ComputedRefImpl + lazy = false + allowRecurse = false + deferStop = false + onStop = undefined // lambda + constructor(fn, scheduler = null) { + this.fn = fn + this.scheduler = scheduler + } + run() { + if (!this.active) { + return this.fn() + } + let parent = activeEffect + let lastShouldTrack = shouldTrack + while (parent) { + if (parent === this) { + return } - } else { - return value + parent = parent.parent } - }, -}) -const collHandler = objHandler - -function createReactive(target, handler) { - const proxy = handler ? new Proxy(target, handler) : target - Object.defineProperty(proxy, '_reactive', { - enumerable: false, - configurable: false, - writable: false, - value: true, - }) - Object.defineProperty(proxy, '_target', { - enumerable: false, - configurable: false, - writable: false, - value: target, - }) - return proxy -} -function reactiveObj(obj) { - return createReactive({ ...obj }, objHandler([])) + try { + this.parent = activeEffect + activeEffect = this + shouldTrack = true + cleanupEffect(this) + return this.fn() + } finally { + activeEffect = this.parent + shouldTrack = lastShouldTrack + this.parent = undefined + if (this.deferStop) { + this.stop() + } + } + } + stop() { + if (activeEffect === this) { // stopped while running itself - defer the cleanup + this.deferStop = true + } else if (this.active) { + cleanupEffect(this) + if (this.onStop) { + this.onStop() + } + this.active = false + } + } } -function reactiveDate(date) { - return createReactive(date, dateHandler()) +function cleanupEffect(effect) { + const { deps } = effect + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + deps[i].delete(effect) + } + deps.length = 0 + } } -function reactivePromise(promise) { - let reactivePromise - reactivePromise = createReactive(promise.finally(() => { - doSideEffects(reactivePromise, '', null, promise) - }), null) // a proxy is not required - return reactivePromise +/** + * Registers the given function to track reactive updates. + * + * The given function will be run once immediately. Every time any reactive + * property that's accessed within it gets updated, the function will run again. + * + * @param fn - The function that will track reactive updates. + * @param options - Allows to control the effect's behaviour. Object that takes: + * lazy: boolean, do not evaluate right now. Default false. + * scheduler: lambda to exec instead of the function, used in computed. Default null. + * allowRecurse: boolean, allow to trigger effect on recursive expression. Default false. + * onStop: lambda to exec when stopping this effect. Default null. + * @returns A runner that can be used to control the effect after creation. + */ +export function effect(fn, options) { + if (fn.effect) { + fn = fn.effect.fn + } + const _effect = new ReactiveEffect(fn) + if (options) { + Object.assign(_effect, options) + } + if (!_effect.lazy) { + _effect.run() + } + const runner = _effect.run.bind(_effect) + runner.effect = _effect + return runner } -function reactiveCollection(coll) { - return createReactive({ ...coll }, collHandler([])) +/** + * Stops the effect associated with the given runner. + * + * @param runner - Association with the effect to stop tracking. + */ +export function stop(runner) { + runner.effect.stop() } -function reactiveRef(value) { - return createReactive({ value }, objHandler(['value'])) +/** + * Temporarily pauses tracking. + */ +export function pauseTracking() { + trackStack.push(shouldTrack) + shouldTrack = false } /** - * It takes a JavaScript object as argument and returns Proxy-based reactive copy of the object. + * Re-enables effect tracking (if it was paused). */ -function reactive(obj) { - if (isObject(obj)) { - if (isCollection(obj)) { - return reactiveCollection(obj) - } else if (isDate(obj)) { - return reactiveDate(obj) - } else if (isPromise(obj)) { - return reactivePromise(obj) - } else { - return reactiveObj(obj) - } - } else { - return obj - } +export function enableTracking() { + trackStack.push(shouldTrack) + shouldTrack = true } /** - * It takes a primitive as argument and returns a reactive mutable object. - * The object has single property ‘value’ and it will point to the primitive argument. + * Resets the previous global effect tracking state. */ -function ref(value) { - return reactiveRef(value) +export function resetTracking() { + const last = trackStack.pop() + shouldTrack = last === undefined ? true : last } -function setActiveEffect(newValue) { - const oldValue = activeEffect - activeEffect = newValue - return oldValue +/** + * Tracks access to a reactive property. + * + * This will check which effect is running at the moment and record it as dep + * which records all effects that depend on the reactive property. + * + * @param target - Object holding the reactive property. + * @param type - Defines the type of access to the reactive property (TrackOpTypes). + * @param key - Identifier of the reactive property to track. + */ +function track(target, type, key) { + if (shouldTrack && activeEffect) { + let depsMap = targetMap.get(target) + if (!depsMap) { + targetMap.set(target, (depsMap = new Map())) + } + let dep = depsMap.get(key) + if (!dep) { + depsMap.set(key, (dep = createDep())) + } + trackEffects(dep) + } } -function setTrackReactiveAccess(newValue) { - const oldValue = trackReactiveAccess - trackReactiveAccess = newValue - return oldValue +function trackEffects(dep) { + if (!dep.has(activeEffect)) { + dep.add(activeEffect) + activeEffect.deps.push(dep) + } } -class ReactiveEffect { - constructor(func) { - this.func = func - this.deps = new Map() +/** + * Finds all deps associated with the target (or a specific property) and triggers the effects stored within. + * + * @param target - The reactive object. + * @param type - Defines the type of the operation that needs to trigger effects (TriggerOpTypes). + * @param key - Can be used to target a specific reactive property in the target object. Could be undefined. + * @param newValue - new value, could be undefined + * @param oldValue - old value, could be undefined + */ +function trigger(target, type, key, newValue, oldValue) { + const depsMap = targetMap.get(target) + if (!depsMap) { // never been tracked + return } - addDep(obj, key) { - let objDeps = this.deps.get(obj) - if (objDeps === undefined) { - this.deps.set(obj, (objDeps = new Set())) + let deps = [] + if (type === TriggerOpTypes.CLEAR) { // collection being cleared + deps = [...depsMap.values()] // trigger all effects for target + } else if (key === 'length' && isArray(target)) { + const newLength = Number(newValue) + depsMap.forEach((dep, key) => { + if (key === 'length' || key >= newLength) { + deps.push(dep) + } + }) + } else { // schedule runs for SET | ADD | DELETE + if (key !== undefined) { + deps.push(depsMap.get(key)) } - objDeps.add(key) - } - run() { - const oldActiveEffect = setActiveEffect(this) - const oldTrackReactiveAccess = setTrackReactiveAccess(true) - this.func() - setActiveEffect(oldActiveEffect) - setTrackReactiveAccess(oldTrackReactiveAccess) - this.deps.forEach((keyset, obj) => { - keyset.forEach(key => { - console.log("adding listener on", obj, ".", key) - listen(obj, key, () => { - console.log(obj, '.', key, 'modified ⇒ update') - this.func() - }) - if (isReactive(obj[key])) { - console.log(`obj.${key} is Reactive, we should probably add it to the listen group`) - console.log("adding listener on", obj[key]) - listen(obj[key], '', () => { - console.log(obj[key], 'modified ⇒ update') - this.func() - }) + switch (type) { // also run for iteration key on ADD | DELETE | Map.SET + case TriggerOpTypes.ADD: + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } else if (isIntegerKey(key)) { // new index added to array -> length changes + deps.push(depsMap.get('length')) } - }) - }) - return this + break + case TriggerOpTypes.DELETE: + if (!isArray(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + if (isMap(target)) { + deps.push(depsMap.get(MAP_KEY_ITERATE_KEY)) + } + } + break + case TriggerOpTypes.SET: + if (isMap(target)) { + deps.push(depsMap.get(ITERATE_KEY)) + } + break + } + } + if (deps.length === 1) { + if (deps[0]) { + triggerEffects(deps[0]) + } + } else { + const effects = [] + for (const dep of deps) { + if (dep) { + effects.push(...dep) + } + } + triggerEffects(createDep(effects)) } } - -function effect(func) { - if (func && typeof func == 'function') { - const reactiveEffect = new ReactiveEffect(func) - return reactiveEffect.run() +function triggerEffects(dep) { + const effects = isArray(dep) ? dep : [...dep] // Set → Array + for (const effect of effects) { + if (effect.computed) { + triggerEffect(effect) + } + } + for (const effect of effects) { + if (!effect.computed) { + triggerEffect(effect) + } + } +} +function triggerEffect(effect) { + if (effect !== activeEffect || effect.allowRecurse) { + if (effect.scheduler) { + effect.scheduler() + } else { + effect.run() + } } } -export { - reactive, - ref, - isReactive, - listen, - listeners, - effect, +class ComputedRefImpl { + #setter + #value + effect + dep = undefined // Set + dirty = true + constructor(getter, setter) { + this.#setter = setter + this.effect = new ReactiveEffect(getter, () => { + if (!this.dirty) { + this.dirty = true + triggerRefValue(this) + } + }) + this.effect.computed = this + } + get [RefFlag]() { return true } + get value() { + const self = toRaw(this) // the computed ref may get wrapped by other proxies + trackRefValue(self) + if (self.dirty) { + self.dirty = false + self.#value = self.effect.run() + } + return self.#value + } + set value(newValue) { + this.#setter(newValue) + } +} +/** + * Takes a getter function and returns a reactive ref object for the returned value from the getter. + * It can also take an object with get and set functions to create a ref object. + * + * @example + * ```js + * // Creating a readonly computed ref: + * const count = ref(1) + * const plusOne = computed(() => count.value + 1) + * console.log(plusOne.value) // 2 + * plusOne.value++ // error + * ``` + * + * ```js + * // Creating a writable computed ref: + * const count = ref(1) + * const plusOne = computed({ + * get: () => count.value + 1, + * set: (val) => { + * count.value = val - 1 + * } + * }) + * plusOne.value = 1 + * console.log(count.value) // 0 + * ``` + * + * @param getterOrOptions - Function that produces the next value or `{get,set}` options + */ +export function computed(getterOrOptions) { + if (isFunction(getterOrOptions)) { + return new ComputedRefImpl(getterOrOptions, () => { + console.warn('Write operation failed: computed value is readonly') + }) + } else { + return new ComputedRefImpl(getterOrOptions.get, getterOrOptions.set) + } } diff --git a/package.json b/package.json index ee8fff1..d83d064 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "author": "Cyrille Pontvieux ", "license": "MIT", "scripts": { - "create-min": "rollup -i microscope/index.mjs -o microscope.min.mjs -f es -p uglify -m --silent", - "gitlab-copy": "mkdir -p public && cp *.html *.mjs *.ico public/ && mv *.min.* public/", + "help": "node -e \"console.log(Object.keys(JSON.parse(require('fs').readFileSync('package.json', 'utf-8'))['scripts']).filter(cmd => cmd != 'help').join('\\n'))\"", + "build": "mkdir -p dist && rollup -i microscope/index.mjs -o dist/microscope.min.mjs -f es -p uglify -m --silent", + "gitlab-copy": "mkdir -p public && cp -v *.html *.mjs *.ico public/ && mv -v dist/*.min.* public/", "gitlab-usemin": "find public -name '*.mjs' -exec sed -ri '1s|/index|.min|' '{}' ';'", - "gitlab-pages": "yarn run create-min && yarn run gitlab-copy && yarn run gitlab-usemin", - "tests": "sh -ec 'for f in test-*.mjs; do node $f $@; done' --" + "gitlab-pages": "yarn run build && yarn run -s gitlab-copy && yarn run -s gitlab-usemin", + "tests": "node ./tests/index.mjs" }, "devDependencies": { "rollup": "^3.25.1", diff --git a/test-reactive.mjs b/test-reactive.mjs deleted file mode 100644 index 270a412..0000000 --- a/test-reactive.mjs +++ /dev/null @@ -1,236 +0,0 @@ -import { reactive, ref, isReactive, listen, listeners, effect } from './microscope/reactive.mjs' -import { - assertEq, - assertIn, - assertIs, - assertIsNot, - assertTrue, - assertFalse, - runTests, -} from './test.mjs' -runTests({ - test_listen() { - const r = ref(4) - assertTrue(() => isReactive(r)) - const newValues = [] - listen(r, 'value', newValue => newValues.push(newValue)) - assertTrue(() => listeners.has(r._target)) - r.value = 5 - assertEq(newValues, [5]) - }, - test_ref_number() { - const r = ref(4) - assertTrue(() => isReactive(r)) - const newValues = [] - listen(r, 'value', newValue => newValues.push(newValue)) - assertIn('value', r) - assertEq(r.value, 4) - r.value += 2 - assertEq(newValues, [6]) - assertEq(r.value , 6) - }, - test_ref_string() { - const r = ref("test") - assertTrue(() => isReactive(r)) - const newValues = [] - listen(r, 'value', newValue => newValues.push(newValue)) - assertIn('value', r) - assertEq(r.value, "test") - r.value = r.value.toUpperCase() - assertEq(newValues, ["TEST"]) - assertEq(r.value , "TEST") - }, - test_ref_obj() { - const o = { - foo: "bar", - baz: "toto", - } - const r = ref(o) - assertTrue(() => isReactive(r)) - const newValues = [] - listen(r, 'value', newValue => newValues.push(newValue)) - assertIsNot(r, o) - assertIn('value', r) - assertIs(r.value, o) - assertEq(r.value, o) - r.value.baz = "tata" - assertEq(newValues, []) - assertEq(r.value, { foo: "bar", baz: "tata"}) - assertEq(o.baz, "tata") - r.value = { another: "object" } - assertEq(newValues, [{ another: "object" }]) - assertEq(o, { foo: "bar", baz: "tata" }) - }, - test_reactive_simple() { - const o = { - foo: "bar", - baz: "toto", - } - const r = reactive(o) - assertTrue(() => isReactive(r)) - const changes = [] - Object.keys(o).forEach(prop => { - listen(r, prop, value => changes.push([prop, value])) - }) - assertIsNot(r, o) - assertIn('foo', r) - assertIn('baz', r) - assertEq(r.foo, "bar") - assertEq(r.baz, "toto") - assertEq(changes, []) - r.baz = "tata" - assertEq(changes, [['baz', "tata"]]) - assertEq(r.baz, "tata") - assertEq(o.baz, "toto") - }, - test_reactive_inner() { - const r1 = reactive({ - foo: "bar", - baz: { inner: "toto" }, - test: null, - }) - assertTrue(() => isReactive(r1)) - const r1Changes = [] - Object.keys(r1).forEach(prop => { - listen(r1, prop, value => r1Changes.push([prop, value])) - }) - listen(r1.baz, 'inner', value => r1Changes.push(['baz.inner', value])) - const r2 = reactive({ - val: 42, - get isTest() { return true }, - increase() { this.val += 1 }, - }) - const r2Changes = [] - Object.keys(r2).forEach(prop => { - listen(r2, prop, value => r2Changes.push([prop, value])) - }) - assertTrue(() => isReactive(r2)) - assertEq(r1.baz, { inner: "toto" }) - assertEq(r1.test, null) - assertTrue(() => isReactive(r1.baz)) - assertFalse(() => isReactive(r1.test)) - r1.test = r2 - assertTrue(() => isReactive(r1.test)) - assertEq(r1Changes, [['test', r2]]) - assertEq(r2Changes, []) - r1.baz.inner = "tutu" - assertEq(r1.test.isTest, true) - r1.test.val *= 2 - r1.test.increase() - assertEq(r1Changes, [['test', r2], ['baz.inner', "tutu"]]) - assertEq(r2Changes, [['val', 84], ['val', 85]]) - }, - test_reactive_bigint() { - const r = reactive({ - b: BigInt(42), - doubleValue() { - this.b *= BigInt(2) - }, - }) - const changes = [] - Object.keys(r).forEach(prop => { - listen(r, prop, value => changes.push([prop, value])) - }) - r.doubleValue() - assertEq(changes, [['b', BigInt(84)]]) - assertEq(r.b, BigInt(84)) - }, - test_reactive_date() { - const r = reactive({ - d: new Date(Date.UTC(2023, 0, 1)), - addDay() { - this.d.setDate(this.d.getDate() + 1) - }, - }) - const changes = [] - Object.keys(r).forEach(prop => { - listen(r, prop, value => changes.push([prop, value])) - }) - const dateChanges = [] - listen(r.d, '', value => dateChanges.push(value)) - r.addDay() - assertEq(changes, []) // date has not been changed on r - assertEq(dateChanges, [new Date(Date.UTC(2023, 0, 2))]) - assertEq(r.d, new Date(Date.UTC(2023, 0, 2))) - }, - async test_reactive_promise() { - let data = null - let promiseResult = null - const fetchPromise = new Promise(resolve => { - let intId - intId = setInterval(() => { - if (data) { - clearInterval(intId) - resolve(data) - } - }, 200) - }) - const testPromise = new Promise(resolve => { - fetchPromise.then(data => { - const res = data.value * 3 - resolve(data.value * 3) - promiseResult = res - }) - }) - const r = reactive({ - p: testPromise, - async setDataValue(value) { - await new Promise(resolve => setTimeout(resolve, 500)) - data = { value } - await new Promise(resolve => setTimeout(resolve, 500)) - }, - }) - const changes = [] - Object.keys(r).forEach(prop => { - listen(r, prop, value => changes.push([prop, value])) - }) - const pChanges = [] - listen(r.p, '', value => { - pChanges.push(value) - }) - assertEq(changes, []) - assertEq(pChanges, []) - await r.setDataValue(5) - assertEq(changes, []) - assertEq(pChanges, [r.p]) - assertEq(promiseResult, 15) - }, - test_reactive_regexp() { - // TODO - }, - test_reactive_weakref() { - // TODO - }, - test_reactive_array() { - // TODO - }, - test_reactive_map() { - // TODO - }, - test_reactive_set() { - // TODO - }, - test_effect() { - const r = reactive({ - v: "microscope", - b: BigInt(42), - d: new Date(Date.UTC(2023, 0, 1)), - r: new RegExp('^micro'), - a: [{ x: 1, y: 2 }, { x: 2, y: 3 }], - m: { get: () => ({ lat: 43, lon: 4 }) }, // TODO new Map([['home', { lat: 43, lon: 4 }]]), - }) - const results = [] - const e = effect(() => { - if (r.r.test(r.v)) { - const position = r.m.get('home') - results.push(position.lat - Number(r.b) + r.a[1].y + r.a[1].y + r.d.getDate()) - } else { - results.push("should not happen") - } - }) - console.log(e.deps) - assertEq(results, [8]) - r.d.setDate(4) - assertEq(results, [8, 11]) - }, -}) diff --git a/test.mjs b/test.mjs deleted file mode 100644 index ef679d2..0000000 --- a/test.mjs +++ /dev/null @@ -1,99 +0,0 @@ -function operandToString(op) { - if (typeof op == 'bigint') { - return op.toString() - } else if (typeof op == 'string') { - return op - } else if (Array.isArray(op)) { - return '[' + op.map(item => operandToString(item)).join(',') + ']' - } else { - return JSON.stringify(op, (key, value) => { - if (typeof value == 'bigint') { - return operandToString(value) - } else if (Array.isArray(value)) { - return operandToString(value) - } else { - return value - } - }) - } -} -class AssertionError extends Error { - constructor(a, b, operator) { - const strA = operandToString(a) - const strB = operandToString(b) - super(`${strA} ${operator} ${strB}`) - this.a = a - this.b = b - this.operator = operator - } -} -function assert(testFunc, assertionError) { - if (!testFunc()) { - throw assertionError - } -} -const assertIs = (a, b) => assert(() => a === b, new AssertionError(a, b, 'is')) -const assertIsNot = (a, b) => assert(() => a !== b, new AssertionError(a, b, 'is not')) -const assertEq = (a, b) => assert(() => operandToString(a) == operandToString(b), new AssertionError(a, b, '=')) -const assertNotEq = (a, b) => assert(() => operandToString(a) != operandToString(b), new AssertionError(a, b, '≠')) -const assertIn = (a, b) => assert(() => a in b, new AssertionError(a, b, 'in')) -const assertNotIn = (a, b) => assert(() => !(a in b), new AssertionError(a, b, 'not in')) -const assertTrue = (lambda) => assert(lambda, new AssertionError('', '', lambda.toString())) -const assertFalse = (lambda) => assert(() => !lambda(), new AssertionError('', '', lambda.toString())) -const ANSI_ESC='\x1B' -const ANSI_RESET=`${ANSI_ESC}[0m` -const ANSI_RED=`${ANSI_ESC}[31m` -const ANSI_GREEN=`${ANSI_ESC}[32m` -function runTests(testObj) { - const args = (() => { - try { // node - return process.argv.slice(2) - } catch(e) { - try { // deno - return Deno.args - } catch(e) { - return [] - } - } - })() - const exit = (() => { - try { // node - return process.exit - } catch(e) { - try { // deno - return Deno.exit - } catch(e) { - return () => {} - } - } - })() - let errors = 0 - Object.keys(testObj).filter( - key => key.startsWith('test_') && typeof testObj[key] == 'function' && (args.length == 0 || args.includes(key)) - ).forEach(async (name) => { - try { - await testObj[name]() - console.log(`${name}: ${ANSI_GREEN}ok${ANSI_RESET}`) - } catch (e) { - console.log(`${name}: ${ANSI_RED}fail${ANSI_RESET}`) - console.trace(e) - errors += 1 - } - }) - if (errors) { - exit(1) - } -} -export { - runTests, - AssertionError, - assert, - assertIs, - assertIsNot, - assertEq, - assertNotEq, - assertIn, - assertNotIn, - assertTrue, - assertFalse, -} diff --git a/tests/index.mjs b/tests/index.mjs new file mode 100644 index 0000000..8699ce8 --- /dev/null +++ b/tests/index.mjs @@ -0,0 +1,149 @@ +import * as Fs from 'node:fs' +import * as Path from 'node:path' +const DIR_PATH = './tests' + +function operandToString(op) { + if (typeof op == 'bigint') { + return op.toString() + } else if (op instanceof WeakRef) { + return '<' + operandToString(op.deref()) + '>' + } else if (typeof op == 'string') { + return op + } else if (Array.isArray(op)) { + return '[' + op.map(item => operandToString(item)).join(',') + ']' + } else { + return JSON.stringify(op, (key, value) => typeof value == 'bigint' || Array.isArray(value) ? operandToString(value) : value) + } +} +class AssertionError extends Error { + constructor(a, b, operator) { + super(`${operandToString(a)} ${operator} ${operandToString(b)}`) + this.a = a + this.b = b + this.operator = operator + } +} +class TestError extends Error {} +function assertFunc(testFunc, assertionError) { + if (!testFunc()) { + throw assertionError + } +} +const assertIs = (a, b) => assertFunc(() => a === b, new AssertionError(a, b, 'is')) +const assertIsNot = (a, b) => assertFunc(() => a !== b, new AssertionError(a, b, 'is not')) +const assertEq = (a, b) => assertFunc(() => operandToString(a) == operandToString(b), new AssertionError(a, b, '=')) +const assertNotEq = (a, b) => assertFunc(() => operandToString(a) != operandToString(b), new AssertionError(a, b, '≠')) +const assertIn = (a, b) => assertFunc(() => a in b, new AssertionError(a, b, 'in')) +const assertNotIn = (a, b) => assertFunc(() => !(a in b), new AssertionError(a, b, 'not in')) +const assertTrue = value => assertFunc(() => value, new AssertionError('', '', value.toString())) +const assertFalse = value => assertFunc(() => !value, new AssertionError('', '', value.toString())) +const ANSI_ESC='\x1B' +const ANSI_RESET=`${ANSI_ESC}[0m` +const ANSI_RED=`${ANSI_ESC}[31m` +const ANSI_GREEN=`${ANSI_ESC}[32m` +function tryOrUndef(fn) { + try { return fn() } catch (e) { return undefined } +} +function getGlobal() { + return tryOrUndef(() => process) ?? tryOrUndef(() => Deno) ?? tryOrUndef(() => window) ?? {} +} +const tests = new Map() +let activeModule = null +function setActiveModule(module) { + activeModule = module +} +function addTest(name, test) { + tests.set(`${activeModule}::${name}`, test) +} +function defineTests(testObj) { + Object.keys(testObj).filter( + key => key.startsWith('test_') && typeof testObj[key] == 'function' + ).forEach(name => { + addTest(name, testObj[name]) + }) +} +async function runner() { + const global = getGlobal() + const args = tryOrUndef(() => [...global.argv.slice(2)]) ?? tryOrUndef(() => [...global.args]) ?? [] + const exit = tryOrUndef(() => global.exit) ?? (() => null) + let dirPath = DIR_PATH + if (args.some(arg => ['-h', '--help', 'help'].includes(arg))) { + console.log(` +tests [OPTIONS] [DIRECTORY=${DIR_PATH}] [TESTS] + +OPTIONS: + -h, --help: this message +DIRECTORY: + by default it’s ${DIR_PATH} +TESTS: + - module::name format to select specific test + - module format to select all tests of that module + - name format to select all tests by that name, through multiple modules + `.trim()) + exit(0) + return + } + const isDir = val => { try { return Fs.statSync(val).isDirectory() } catch (e) { return false } } + if (args.length && args[0] && isDir(args[0])) { + dirPath = Fs.realpathSync(args.shift()) + } else { + dirPath = Fs.realpathSync(dirPath) + } + console.log(`Searching in ${dirPath}`) + const testFiles = Fs.readdirSync( + dirPath, { recursive: true }, + ).filter( + file => Fs.statSync(Path.join(dirPath, file)).isFile() && Path.parse(file).name.startsWith('test_'), + ) + for (const file of testFiles) { + const fileParsed = Path.parse(file) + const moduleName = Path.join(fileParsed.dir, fileParsed.name) + setActiveModule(moduleName) + const filePath = Path.join(dirPath, file) + console.log(`Importing module ${fileParsed.name} (${file})`) + await import(filePath) + } + const selectedTests = [...tests].filter(([key, func]) => { + if (args.length) { + const [module, name] = key.split('::') + return args.includes(key) || args.includes(module) || args.includes(name) + } else { + return true + } + }) + let errors = 0 + for (const [name, func] of selectedTests) { + try { + await func() + console.log(`${name}: ${ANSI_GREEN}ok${ANSI_RESET}`) + } catch (e) { + console.log(`${name}: ${ANSI_RED}fail${ANSI_RESET}`) + console.trace(e) + errors += 1 + } + } + if (errors) { + exit(1) + } +} +const assert = { + Error: AssertionError, + Func: assertFunc, + Is: assertIs, + IsNot: assertIsNot, + Eq: assertEq, + NotEq: assertNotEq, + In: assertIn, + NotIn: assertNotIn, + True: assertTrue, + False: assertFalse, +} +export { + defineTests, + runner, + assert, +} +function isMain() { return import.meta.main ?? new URL(import.meta.url).pathname == tryOrUndef(() => process)?.argv[1] } +if (isMain()) { + runner() +} diff --git a/tests/test_reactive.mjs b/tests/test_reactive.mjs new file mode 100644 index 0000000..c38d684 --- /dev/null +++ b/tests/test_reactive.mjs @@ -0,0 +1,190 @@ +import { reactive, ref, isReactive, isRef, effect, computed } from '../microscope/reactive.mjs' +import { assert, defineTests } from './index.mjs' +defineTests({ + test_ref_number() { + const r = ref(4) + assert.True(isRef(r)) + assert.False(isReactive(r)) + assert.In('value', r) + assert.Eq(r.value, 4) + const newValues = [] + effect(() => newValues.push(r.value)) + assert.Eq(newValues, [4]) + r.value += 2 + assert.Eq(r.value , 6) + assert.Eq(newValues, [4, 6]) + }, + test_ref_string() { + const r = ref("test") + assert.True(isRef(r)) + assert.False(isReactive(r)) + assert.In('value', r) + assert.Eq(r.value, "test") + const newValues = [] + effect(() => newValues.push(r.value)) + assert.Eq(newValues, ["test"]) + r.value = r.value.toUpperCase() + assert.Eq(r.value , "TEST") + assert.Eq(newValues, ["test", "TEST"]) + }, + test_ref_obj() { + const o = { + foo: "bar", + baz: "toto", + } + const r = ref(o) + assert.True(isRef(r)) + assert.False(isReactive(r)) + assert.In('value', r) + assert.Eq(r.value, o) + assert.IsNot(r.value, o) + const newValues = [] + effect(() => newValues.push(r.value)) + assert.Eq(newValues, [{ foo: "bar", baz: "toto" }]) + r.value.baz = "tata" + assert.Eq(r.value, { foo: "bar", baz: "tata" }) + assert.Eq(o.baz, "tata") + assert.Eq(newValues, [{ foo: "bar", baz: "tata" }]) + r.value = { another: "object" } + assert.Eq(newValues, [{ foo: "bar", baz: "tata" }, { another: "object" }]) + assert.Eq(o, { foo: "bar", baz: "tata" }) + }, + test_reactive_simple() { + const o = { + foo: "bar", + baz: "toto", + } + const r = reactive(o) + assert.False(isRef(r)) + assert.True(isReactive(r)) + assert.Eq(r, o) + assert.IsNot(r, o) + assert.In('foo', r) + assert.In('baz', r) + assert.Eq(r.foo, "bar") + assert.Eq(r.baz, "toto") + const changes = [] + effect(() => changes.push(r)) + assert.Eq(changes, [{ foo: "bar", baz: "toto" }]) + r.baz = "tata" + assert.Eq(changes, [{ foo: "bar", baz: "tata" }]) + assert.Eq(r.baz, "tata") + assert.Eq(o.baz, "tata") + const bazChanges = [] + effect(() => bazChanges.push(r.baz)) + assert.Eq(bazChanges, ["tata"]) + r.baz = "titi" + assert.Eq(changes, [{ foo: "bar", baz: "titi" }]) + assert.Eq(bazChanges, ["tata", "titi"]) + }, + test_reactive_inner() { + const r1 = reactive({ + foo: "bar", + baz: { inner: "toto" }, + test: null, + }) + assert.True(isReactive(r1)) + assert.Eq(r1.baz, { inner: "toto" }) + assert.True(isReactive(r1.baz)) + assert.Eq(r1.test, null) + assert.False(isReactive(r1.test)) + const changes = [] + effect(() => changes.push(r1)) + effect(() => changes.push(['test', r1.test])) + assert.Eq(changes, [ + { foo: "bar", baz: { inner: "toto" }, test: null }, + ['test', null], + ]) + const r2 = reactive({ + val: 42, + get isTest() { return true }, + increase() { this.val += 1 }, + }) + effect(() => changes.push(r2.val)) + assert.Eq(changes, [ + { foo: "bar", baz: { inner: "toto" }, test: null }, + ['test', null], + 42, + ]) + assert.True(isReactive(r2)) + r1.test = r2 + assert.True(isReactive(r1.test)) + assert.Eq(changes, [ + { foo: "bar", baz: { inner: "toto" }, test: { ...r2, val: 42 } }, + ['test', null], + 42, + ['test', { ...r2, val: 42 }], + ]) + assert.Eq(r1.test.isTest, true) + r1.baz.inner = "tutu" + r1.test.val *= 2 + r1.test.increase() + assert.Eq(changes, [ + { foo: "bar", baz: { inner: "tutu" }, test: { ...r2, val: 85 } }, + ['test', null], + 42, + ['test', { ...r2, val: 85 }], + 84, + 85, + ]) + }, + test_reactive_effects() { + const r = reactive({ + v: "microscope", + b: BigInt(42), + d: new Date(Date.UTC(2023, 0, 1)), + r: new RegExp('^micro'), + a: [{ x: 1, y: 2 }, { x: 2, y: 3 }], + m: new Map([['home', { lat: 43, lon: 4 }]]), + }) + const results = [] + effect(() => { + if (r.r.test(r.v) && r.m.has('home')) { + const position = r.m.get('home') + results.push(position.lat - Number(r.b) + r.a[0].x + r.a[1].y + r.d.getDate()) + } else { + results.push("not micro") + } + }) + assert.Eq(results, [6]) // 43 - 42 + 1 + 3 + 1 + const d2 = new Date(r.d) + d2.setDate(4) + r.d = d2 + assert.Eq(results, [6, 9]) + r.v = "big framework" + assert.Eq(results, [6, 9, "not micro"]) + r.r = new RegExp("frame") + r.m.delete('home') + r.b /= BigInt(2) + r.a.unshift({x: 2, y: 0 }) + assert.Eq(results, [6, 9, "not micro", 9, "not micro"]) + r.m.set('home', { lat: 42, lon: 6 }) + assert.Eq(results, [6, 9, "not micro", 9, "not micro", 29]) // 42 - 21 + 2 + 2 + 4 + }, + test_computed() { + const r = reactive({ + v: "microscope", + b: BigInt(42), + d: new Date(Date.UTC(2023, 0, 1)), + r: new RegExp('^micro'), + a: [{ x: 1, y: 2 }, { x: 2, y: 3 }], + m: new Map([['home', { lat: 43, lon: 4 }]]), + }) + const c = computed(() => { + if (r.r.test(r.v) && r.m.has('home')) { + const position = r.m.get('home') + return position.lat - Number(r.b) + r.a[0].x + r.a[1].y + r.d.getDate() + } else { + return "not micro" + } + }) + const c2 = computed(() => 2 * c.value) + assert.True(isRef(c)) + assert.True(isRef(c2)) + assert.Eq(c.value, 6) + assert.Eq(c2.value, 12) + r.a[1].y += 3 + assert.Eq(c.value, 9) + assert.Eq(c2.value, 18) + }, +}) -- GitLab