diff --git a/.gitignore b/.gitignore index 7fc1370666a470e5754dc20ad22d2ff30d7cf16e..6530ed18457d149d7ca34fb92798057c54823b01 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 9eb565e87d5f9a877e41798989c031131f7f8ff2..ed1eba4256ff0d3d3b9cc413f646dabae8323f97 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/app.mjs b/microscope/app.mjs index 5cf81c96bb3057aeb1f0dfc4f13aa109521ba5ef..2fe0da1a0fc3db0703657eb5297d4cced0d427cd 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 61d066947827bc501efdf571c87f556f66e13f5a..3c95e475bbd13995927812169f850842573981fe 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 1e1d97f1ea67548bf069b0d3f53cae3b596d2816..4319fcbcf46fa388c1a83f2a0cd7b04e76b030b0 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/explanations.md b/microscope/explanations.md new file mode 100644 index 0000000000000000000000000000000000000000..9829c22af89527189fda5021517ac877c306fdac --- /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 77e853a8e14628029b5c95da0d002b42fb2e7200..756b168b2d4ca249ede521411f69952b2b2b8662 100644 --- a/microscope/index.mjs +++ b/microscope/index.mjs @@ -1,11 +1,20 @@ import { Component } from './component.mjs' import { App } from './app.mjs' +export * from './reactive.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/reactive.mjs b/microscope/reactive.mjs new file mode 100644 index 0000000000000000000000000000000000000000..78ee91a3af0ef5ce472ebb883af0701b5f4ec36c --- /dev/null +++ b/microscope/reactive.mjs @@ -0,0 +1,973 @@ +/* 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>> +// + +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 + } +} + +function getTargetType(value) { + return value[ReactiveFlags.SKIP] || !Object.isExtensible(value) ? TargetType.INVALID : targetTypeMap(toRawType(value)) +} + +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(), + } +})() + +/** + * 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 +} + +/** + * 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]) +} + +/** + * Checks if an object is a proxy created by {@link reactive} + * + * @param value - The value to check. + */ +export function isProxy(value) { + return isReactive(value) +} + +/** + * 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 +} + +/** + * 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 +} + +/** + * 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 triggerRefValue(ref) { + ref = toRaw(ref) + const dep = ref.dep + if (dep) { + triggerEffects(dep) + } +} +/** + * Checks if a value is a ref object. + * + * @param value - The value to inspect. + */ +export function isRef(value) { + return !!(value && value[RefFlag] === true) +} +/** + * 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) + } + } +} +/** + * 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 { + return ref(source) + } +} +function propertyToRef(source, key, defaultValue) { + const val = source[key] + return isRef(val) ? val : new ObjectRefImpl(source, key, defaultValue) +} + +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 + } + parent = parent.parent + } + 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 cleanupEffect(effect) { + const { deps } = effect + if (deps.length) { + for (let i = 0; i < deps.length; i++) { + deps[i].delete(effect) + } + deps.length = 0 + } +} +/** + * 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 +} +/** + * Stops the effect associated with the given runner. + * + * @param runner - Association with the effect to stop tracking. + */ +export function stop(runner) { + runner.effect.stop() +} +/** + * Temporarily pauses tracking. + */ +export function pauseTracking() { + trackStack.push(shouldTrack) + shouldTrack = false +} +/** + * Re-enables effect tracking (if it was paused). + */ +export function enableTracking() { + trackStack.push(shouldTrack) + shouldTrack = true +} +/** + * Resets the previous global effect tracking state. + */ +export function resetTracking() { + const last = trackStack.pop() + shouldTrack = last === undefined ? true : last +} + +/** + * 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 trackEffects(dep) { + if (!dep.has(activeEffect)) { + dep.add(activeEffect) + activeEffect.deps.push(dep) + } +} + +/** + * 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 + } + 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)) + } + 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')) + } + 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 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() + } + } +} + +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/microscope/vdom-renderer.mjs b/microscope/vdom-renderer.mjs index 8990c8057055192c353fc616b19b3dbe6e7bbfe8..6e57afd51dc6a00209510b5c86d461d43ce51b1c 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 0000000000000000000000000000000000000000..0b5f3773875ea78935eb39964335fef6279ba36d --- /dev/null +++ b/onefile.html @@ -0,0 +1,57 @@ + + + + + Microscope - micro reactive components + + + +
+ +
+ + + diff --git a/package.json b/package.json index 3660cf67b416645713672e46f0a1c90a2fc8d167..d83d06415d1a181d7093d011f6921f829af28048 100644 --- a/package.json +++ b/package.json @@ -7,10 +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" + "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/tests/index.mjs b/tests/index.mjs new file mode 100644 index 0000000000000000000000000000000000000000..8699ce8c4ccfef25f8bdd20eba4d22911585b632 --- /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 0000000000000000000000000000000000000000..c38d68446243f71e4eff23b03018beca13356b82 --- /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) + }, +})