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
+
+
+
+
+
+ Hello {{ user.name }}
+ Your role: {{ user.role.toUpperCase() }}
+
+
You have {{ newMessages.value.length }} new messages
+
+
+ -
+
- {{ $index + 1 }} - {{ msg.subject }}
- {{ msg.body }}
+
+
+
+
+
+
+
+
+
+
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)
+ },
+})