module Reflect = { external has: ('a, string) => bool = "Reflect.has" external get: ('a, string) => 'b = "Reflect.get" external maybeGet: ('a, string) => nullable<'b> = "Reflect.get" external set: ('a, string, 'b) => bool = "Reflect.set" external deleteProperty: ('a, string) => bool = "Reflect.deleteProperty" external ownKeys: 'a => 'b = "Reflect.ownKeys" } let raise: string => 'a = %raw(`function (message) { throw new Error(message) }`) let reraise: 'a => 'b = %raw(`function (e) { console.error("Reraising exception after flush"); throw e }`) /** Opaque handle for a [changing] subscriber stored in [meta.changes]. */ type changeCb external makeCb: ((string, 'v) => unit) => changeCb = "%identity" let callCb: (changeCb, string, 'a) => unit = %raw(`function(cb, k, v) { cb(k, v) }`) %%raw(` function cleanTrace(stack) { if (typeof stack !== "string") return stack; const cleaned = ["Exception thrown in computed or observe"]; let collapsing = false; for (const line of stack.split("\n")) { if (/src\/Tilia\.mjs:\d+:\d+/.test(line)) { if (!collapsing) { cleaned.push(" [... tilia internals]"); collapsing = true; } } else { cleaned.push(line); collapsing = false; } } return cleaned.join("\n"); }`) module Proxy = { @new external make: ('a, 'b) => 'c = "Proxy" } let symbol: string => string = %raw(` function(s) { return Symbol.for('tilia:' + s); } `) let defaultGc = 50 // Called when something changes in the index (added or removed keys) let indexKey = symbol("indexKey") // Used to get meta information (mostly for stats) let metaKey = symbol("metaKey") // Mark a function as being a compute value let dynamicKey = symbol("dynamicKey") // Default context let ctxKey = symbol("ctx") type compute<'a> = {mutable rebuild: unit => 'a} type source<'a, 'ignored> = { source: ('a, 'a => unit) => 'ignored, value: 'a, } type store<'a> = {store: ('a => unit) => 'a} type dynamic<'a, 'b> = | Computed(unit => 'a) | Source(source<'a, 'b>) | Store(store<'a>) | Compiled(compute<'a>) module Typeof = { external array: 'a => bool = "Array.isArray" let proxiable: 'a => bool = %raw(` function(v) { if ( typeof v === 'object' && v !== null) { const proto = Object.getPrototypeOf(v) return proto === Object.prototype || proto === Array.prototype || proto === null } return false; } `) let dynamic: 'a => nullable> = %raw(` function(v) { return typeof v === 'object' && v !== null && v[dynamicKey] ? v : undefined; } `) } module Object = { type descriptor<'a> = {writable: bool, enumerable: bool, configurable: bool, value: 'a} external hasOwn: ('a, string) => bool = "Object.hasOwn" external getOwnPropertyDescriptor: ('a, string) => nullable> = "Object.getOwnPropertyDescriptor" let readonly: ('a, string) => bool = (o, k) => { switch getOwnPropertyDescriptor(o, k) { | Value(d) => d.writable === false | _ => false } } external defineProperty: ('a, string, descriptor<'b>) => unit = "Object.defineProperty" } module Dict = { type t<'a> @new external make: unit => t<'a> = "Map" @send external get: (t<'a>, string) => nullable<'a> = "get" @send external set: (t<'a>, string, 'b) => unit = "set" @send external delete: (t<'a>, string) => unit = "delete" @get external size: t<'a> => int = "size" @send external forEach: (t<'a>, ('a, string) => unit) => unit = "forEach" } type dict<'a> = Dict.t<'a> type error type state = | Pristine // Hasn't changed since value read. | Changed // Value changed and has been notified. | Cleared // No more observer registered: cleared. type rec observer = { // We set root in the observer so that most methods do not need to // be recreated in the tilia context. root: root, // Function to call on notify. notify: unit => unit, // What this observer is observing (a list of watchers) observing: observing, } // Observed key => watchers and observed = dict // Who to notify on change and observers = Set.t // Observers observing a given key in an object/array. We // are mainly interested in the 'observers' set but we need // the other attributes like state/key/observed to manage edge // cases. and watchers = { mutable state: state, // Tracked key in parent key: string, // Parent tracking observed: observed, // Set of observers to notify on change. observers: observers, } and gc = { mutable active: Set.t, mutable quarantine: Set.t, threshold: int, } and root = { mutable observer: nullable, // List of watchers to clear on next flush mutable expired: Set.t, // If set to true, wait for end of batch before flush mutable lock: bool, mutable error: nullable, id: float, // Garbage collection handling gc: gc, } // List of watchers to which the the observer should add itself on ready and observing = array let setError: (root, 'a) => unit = %raw(`function (root, e) { if (typeof e === "object" && e !== null) { if (e.stack) { console.error(cleanTrace(e.stack)); } else { console.error(e); } root.error = e; } }`) /** Per-proxy reactive state. Same JS object as [node] after [target] is set (see [proxify]). */ type rec meta<'a> = { /** Backing object or array (not the Proxy). */ target: 'a, /** Scheduler root: active observer, batch lock, GC, errors. */ root: root, /** Watchers per string key; [indexKey] tracks ownKeys / array length. */ observed: dict, /** Cached child branch meta (nested proxies keyed by property name). */ proxied: dict>, /** Per-key clear callbacks for installed computed values. */ computes: dict unit>, /** Optional listeners for [changes]; each cb receives (key, value) from proxy handler. */ mutable changes: nullable>, /** The Proxy wrapping [target]. */ mutable proxy: 'a, } /** Fields closed over by Proxy handlers before [target] and [proxy] exist on the object. */ type node<'c> = { /** See [meta.root]. */ root: root, /** See [meta.observed]. */ observed: dict, /** See [meta.proxied]. */ proxied: dict>, /** See [meta.computes]. */ computes: dict unit>, /** See [meta.changes]. */ mutable changes: nullable>, } type signal<'a> = {mutable value: 'a} type readonly<'a> = {data: 'a} type setter<'a> = 'a => unit type deriver<'p> = { /** * Return a derived value to be inserted into a tilia object. This is like * a computed but with the tilia object as parameter. * * @param f The computation function that takes the tilia object as parameter. */ derived: 'a. ('p => 'a) => 'a, } type tilia = { tilia: 'a. 'a => 'a, carve: 'a. (deriver<'a> => 'a) => 'a, observe: (unit => unit) => unit, watch: 'a. (unit => 'a, 'a => unit) => unit, batch: (unit => unit) => unit, signal: 'a. 'a => (signal<'a>, setter<'a>), derived: 'a. (unit => 'a) => signal<'a>, source: 'a 'ignored. ('a, ('a, 'a => unit) => 'ignored) => 'a, store: 'a. (('a => unit) => 'a) => 'a, /** internal */ _observe: (unit => unit) => observer, } let _meta: 'a => nullable> = p => Reflect.get(p, metaKey) @inline let _observe = (root, notify) => { let observer = {root, notify, observing: []} root.observer = Value(observer) observer } let observeKey = (observed, key) => { switch Dict.get(observed, key) { | Value(w) => w | _ => { let w = { state: Pristine, key, observed, observers: Set.make(), } Dict.set(observed, key, w) w } } } let flush = root => { if Set.size(root.expired) > 0 { while Set.size(root.expired) > 0 { let expired = root.expired root.expired = Set.make() // We need to notify Set.forEach(expired, observer => observer.notify()) } } let gc = root.gc if Set.size(gc.active) >= gc.threshold { Set.forEach(gc.quarantine, w => { if w.state === Pristine && Set.size(w.observers) === 0 { w.state = Cleared Dict.delete(w.observed, w.key) } }) gc.quarantine = gc.active gc.active = Set.make() } if root.error !== Undefined { let e = root.error root.error = Undefined reraise(e) } } let _clear = (observer: observer) => { let root = observer.root Array.forEach(observer.observing, watchers => { if ( watchers.state == Pristine && // No need to delete if state isn't Pristine Set.delete(watchers.observers, observer) && Set.size(watchers.observers) === 0 ) { // Add to gc set Set.add(root.gc.active, watchers) } }) switch root.observer { | Value(o) if o === observer => { root.observer = Undefined if !root.lock { flush(root) } } | _ => () } } let _ready = (observer: observer, notifyIfChanged: bool) => { ignore( Array.findWithIndex(observer.observing, (w, idx) => { switch w.state { | Pristine => { ignore(Set.add(w.observers, observer)) false } | Changed | Cleared if notifyIfChanged => { _clear(observer) observer.notify() true // abort find } | _ => { // Cleared or Changed, but without notifyIfChanged. // We need to re-subscribe to the key let w = observeKey(w.observed, w.key) ignore(Set.add(w.observers, observer)) observer.observing[idx] = w false } } }), ) let root = observer.root switch root.observer { | Value(o) if o === observer => { root.observer = Undefined if !root.lock { flush(root) } } | _ => () } } let ownKeys = (root: root, observed: dict, target: 'a): 'b => { let keys = Reflect.ownKeys(target) switch root.observer { | Value(o) => { let w = observeKey(observed, indexKey) Array.push(o.observing, w) } | _ => () } keys } let notify = (root, observed, key) => { switch Dict.get(observed, key) { | Value(watchers) => { // No need to remember this observed key Dict.delete(observed, key) watchers.state = Changed let expired = root.expired // Notify Set.forEach(watchers.observers, observer => { _clear(observer) Set.add(expired, observer) }) switch root.observer { | Value(_) => () // No observers, we can flush | _ => if !root.lock { flush(root) } } } | _ => () } } @inline let sourceCallback = (set, source: source<'a, 'b>) => { let v = source.value let val = ref(v) let set = v => { val := v set(v) } // Set initial value set(v) () => { // source.source can be a computed. We resolve inside the callback. let callback = source.source callback(val.contents, set) val.contents } } @inline let storeCallback = (set, store: store<'a>) => { // store.store can be a computed. We resolve inside the callback. () => store.store(set) } @inline let getValue = (compile, set, value) => { let rec get = value => { switch Typeof.dynamic(value) { | Undefined | Null => value | Value(dynamic) => { let v = switch dynamic { | Compiled({rebuild}) => rebuild() | Computed(callback) => compile(callback) | Source(source) => compile(sourceCallback(set, source)) | Store(store) => compile(storeCallback(set, store)) } Typeof.proxiable(v) ? get(v) : v } } } get(value) } type observerRef = {mutable o: nullable} type lastValue<'a> = {mutable v: 'a} let rec set = ( node: node<'c>, isArray: bool, fromComputed: bool, target: 'a, key: string, value: 'b, ) => { let hadKey = Reflect.has(target, key) let prev = Reflect.get(target, key) let proxiable = Typeof.proxiable(value) let same = if proxiable { switch _meta(value) { | Value(m) => m.target === prev | _ => prev === value } } else { prev === value } if same { true } else { switch Reflect.set(target, key, value) { | false => false | true => let writeKey = key let key = if isArray && key === "length" { indexKey } else { key } if proxiable { Dict.delete(node.proxied, key) } if !fromComputed { // Not a computed rebuild: clear any existing compute on this key switch Dict.get(node.computes, key) { | Value(clear) => { Dict.delete(node.computes, key) clear() } | _ => () } } switch Typeof.dynamic(value) { | Undefined | Null => { if !fromComputed { switch node.changes { | Value(cbs) => Set.forEach(cbs, cb => callCb(cb, writeKey, value)) | _ => () } } notify(node.root, node.observed, key) if !hadKey { // new key: trigger index notify(node.root, node.observed, indexKey) } true } | Value(_) => // Dynamic value switch Dict.get(node.observed, key) { | Value(w) if w.state === Pristine && Set.size(w.observers) > 0 => { // Computed value is observed: rebuild and notify if changed // Put back previous value to detect changes ignore(Reflect.set(target, key, prev)) let compile = callback => compile(node, isArray, target, key, callback) let setter = v => ignore(set(node, isArray, true, target, key, v)) let v = getValue(compile, setter, value) set(node, isArray, true, target, key, v) } | _ => true } } } } } and compile = (node: node<'c>, isArray: bool, target: 'a, key: string, callback: unit => 'b) => { let lastValue: lastValue<'b> = {v: %raw(`undefined`)} let observer = {o: Undefined} // Initial compute has raw callback as rebuild method let compute: compute<'b> = { rebuild: %raw(`undefined`), } let compiled = Compiled(compute) ignore(Reflect.set(compiled, dynamicKey, true)) let rec notify = () => { let v = Reflect.get(target, key) if v !== compiled { lastValue.v = v } switch Dict.get(node.observed, key) { | Value(w) if Set.size(w.observers) > 0 => // We have active observers on this key. // Rebuild and if the key changed, it will notify. ignore(set(node, isArray, true, target, key, rebuild())) | Value(w) => { // No active listeners. w.state = Changed Dict.delete(node.observed, key) ignore(Dict.delete(node.proxied, key)) ignore(Reflect.set(target, key, compiled)) } | _ => { // We do not have any observers: reset value. // Make sure the previous proxy (if any) is removed so that it is not // served in place of triggering the compute. ignore(Dict.delete(node.proxied, key)) ignore(Reflect.set(target, key, compiled)) } } } and rebuild = () => { // Make sure any further read gets the last value until we are done with // rebuilding the value. This is also useful if the callback needs the // previous value and to detect unchanged value. If possible, the callback // should avoid accessing this value because it can be undefined on first // run and breaks typing. let curr = Reflect.get(target, key) if curr === compiled { ignore(Reflect.set(target, key, lastValue.v)) } else { lastValue.v = curr } let o: observer = {root: node.root, notify, observing: []} observer.o = Value(o) let previous = node.root.observer node.root.observer = Value(o) let v = try { callback() } catch { | _e => { setError(node.root, %raw(`_e`)) _clear(o) switch previous { | Value(previous) => _clear(previous) | _ => () } lastValue.v } } // We need to reset previous observer before calling setReady so that // setReady does not trigger flush. node.root.observer = previous // Computed should not rebuild on change. if Array.length(o.observing) === 0 { // FIXME: How to clear if the rebuild is observing itself ? _clear(o) Dict.delete(node.computes, key) } else { _ready(o, false) } v } compute.rebuild = rebuild let clear = () => { // Clear our cache clearing observer switch observer.o { | Value(o) => { _clear(o) // Empty in case "ready" is called after clear (but we only // do this for computed). ignore(%raw(`o.observing.length = 0`)) } | _ => () } } Dict.set(node.computes, key, clear) rebuild() } let deleteProperty = (node: node<'c>, target: 'a, key: string) => { let res = Reflect.deleteProperty(target, key) Dict.delete(node.proxied, key) switch Dict.get(node.computes, key) { | Value(clear) => { Dict.delete(node.computes, key) clear() } | _ => () } switch node.changes { | Value(cbs) => Set.forEach(cbs, cb => callCb(cb, key, %raw(`undefined`))) | _ => () } notify(node.root, node.observed, key) res } let rec get = (node: node<'c>, meta: meta<'a>, isArray: bool, target: 'a, key: string): 'b => { if key === metaKey { // We use this to avoid argument removal by compilation (it is not included // in JS compiled code). ignore(meta) // We use raw to avoid type %raw(`meta`) } else if key === dynamicKey { %raw(`undefined`) } else { let value = Reflect.get(target, key) let own = Object.hasOwn(target, key) if value === undefined || own { switch node.root.observer { | Value(o) => if isArray && key == "length" { let w = observeKey(node.observed, indexKey) Array.push(o.observing, w) } else { let w = observeKey(node.observed, key) Array.push(o.observing, w) } | _ => () } if Typeof.proxiable(value) && !Object.readonly(target, key) { switch Dict.get(node.proxied, key) { | Value(m) => m.proxy | _ => { let compile = callback => compile(node, isArray, target, key, callback) let setter = v => ignore(set(node, isArray, true, target, key, v)) let v = getValue(compile, setter, value) ignore(Reflect.set(target, key, v)) if Typeof.proxiable(v) { let m = proxify(node.root, v) Dict.set(node.proxied, key, m) m.proxy } else { v } } } } else { // Not proxiable or readonly value } } else { // Prototype value value } } } and proxify = (root: root, target: 'a): meta<'a> => { switch _meta(target) { | Value(m) if m.root === root => m | Value(m) => proxify(root, m.target) | _ => let node: node<'b> = { root, observed: Dict.make(), proxied: Dict.make(), computes: Dict.make(), changes: Undefined, } let meta: meta<'a> = %raw(`(node.target = target, node)`) let isArray = Typeof.array(target) let proxy = Proxy.make( target, { "set": set(node, isArray, false, ...), "deleteProperty": deleteProperty(node, ...), "get": get(node, meta, isArray, ...), "ownKeys": ownKeys(node.root, node.observed, ...), }, ) meta.proxy = proxy meta } } let _done = (o: observer) => o.root.observer = Undefined let makeTilia = (root: root) => (value: 'a) => { if !Typeof.proxiable(value) { raise("tilia: value is not an object or array") } proxify(root, value).proxy } let makeDerived = p => fn => { let v = Computed(() => fn(p.contents)) ignore(Reflect.set(v, dynamicKey, true)) %raw(`v`) } external makeReactive: ( // derived ('p => 'a) => 'a ) => deriver<'p> = "makeReactive" %%raw(` function makeReactive(derived) { return { derived } } `) let makeCarve = (root: root) => (fn: deriver<'a> => 'a) => { let p = ref(%raw(`{}`)) let ctx = makeReactive(makeDerived(p)) let value = fn(ctx) if !Typeof.proxiable(value) { raise("tilia: value is not an object or array") } let value = proxify(root, value).proxy p := value value } let makeObserve = (root: root) => (callback: unit => unit) => { let rec notify = () => { let o = _observe(root, notify) try { callback() _ready(o, true) } catch { | _e => { setError(root, %raw(`_e`)) _clear(o) } } } notify() } let makeWatch = (root, observe_) => (callback: unit => 'a, effect: 'a => unit) => { let rec notify = () => { let o = observe_(notify) let v = callback() _done(o) if root.lock { effect(v) } else { root.lock = true effect(v) root.lock = false } _ready(o, false) } // First registration: effect not called let o = observe_(notify) ignore(callback()) _ready(o, false) } let makeBatch = (root: root) => (callback: unit => unit) => { if root.lock { callback() } else { root.lock = true callback() root.lock = false flush(root) } } let orphanError = "Cannot modify or access the value of an orphan computation. See https://tiliajs.com/errors#orphan" let warningHandler = () => { "set": (_, _key, _value) => { raise(orphanError) }, "deleteProperty": (_, _key) => { raise(orphanError) }, "get": (target, key) => { // Allow access ONLY to internal properties that Tilia needs to identify dynamic values if key === dynamicKey || key === metaKey || key === "TAG" || key === "_0" { Reflect.get(target, key) } else { // Throw error for any other property access, including valueOf, toString, toJSON // These are exactly what we want to catch (e.g., trouble * 2 calls valueOf) raise(orphanError) } }, "ownKeys": target => Reflect.ownKeys(target), } let computed = fn => { let v = Computed(fn) ignore(Reflect.set(v, dynamicKey, true)) Proxy.make(v, warningHandler()) } let makeSource = tilia => (value, source) => { ignore(tilia) let s = switch Typeof.dynamic(source) { | Value(d) => { // We wrap source in tilia so that computed in the source can be resolved. // Example: source("foo", derived(getFoo)) ignore(d) %raw(`tilia({value, source: d})`) } // We do not wrap non-dynamic sources to avoid unnecessary wrapping and allow computed removal for // non-dynamic values. | _ => {value, source} } let v = Source(s) ignore(Reflect.set(v, dynamicKey, true)) %raw(`v`) } let makeStore = tilia => callback => { ignore(tilia) let s = switch Typeof.dynamic(callback) { | Value(d) => { // We wrap store in tilia so that computed in the store can be resolved. // Example: store(derived(getFoo)) ignore(d) %raw(`tilia({store: d})`) } // We do not wrap non-dynamic sources to avoid unnecessary wrapping and allow computed removal for // non-dynamic values. | _ => {store: callback} } let v = Store(s) ignore(Reflect.set(v, dynamicKey, true)) %raw(`v`) } @inline let makeSignal = (tilia: signal<'a> => signal<'a>) => (value: 'c) => { let s = tilia({value: value}) let set = v => s.value = v (s, set) } let makeDerived = (tilia: signal<'a> => signal<'a>) => (fn: 'c) => tilia({value: computed(() => fn())}) let _done = (o: observer) => o.root.observer = Undefined /* We use this external hack to have polymorphic functions (without this, they * become monomorphic). */ external connector: ( // tilia 'a => 'a, // carve (deriver<'c> => 'c) => 'c, // observe (unit => unit) => unit, // watch (unit => 'w, 'w => unit) => unit, // batch (unit => unit) => unit, // extra // signal 's => (signal<'s>, setter<'s>), // derived (unit => 't) => signal<'t>, // source ('t, ('t, 't => 'u) => 'v) => 't, // store ('w => unit) => 'w, // internal // _observe (unit => unit) => observer, ) => tilia = "connector" %%raw(` function connector(tilia, carve, observe, watch, batch, signal, derived, source, store, _observe) { return { tilia, carve, observe, watch, batch, // extra signal, derived, source, store, // internal _observe, }; } `) let make = (~gc=defaultGc): tilia => { let gc = { active: Set.make(), quarantine: Set.make(), threshold: gc, } let root = { observer: Undefined, expired: Set.make(), lock: false, gc, error: Undefined, id: Math.random(), } // We need to use raw to hide the types here. let tilia = makeTilia(root) let _observe = _observe(root, ...) connector( tilia, makeCarve(root), makeObserve(root), makeWatch(root, _observe), makeBatch(root), // extra makeSignal(tilia), makeDerived(tilia), makeSource(tilia), makeStore(tilia), // Internal _observe, ) } // Default context let _ctx = switch Reflect.maybeGet(globalThis, ctxKey) { | Value(ctx) => ctx | _ => { let ctx = make() ignore(Reflect.set(globalThis, ctxKey, ctx)) ctx } } let readonly = (data: 'a) => { let obj: readonly<'a> = %raw(`{}`) Object.defineProperty( obj, "data", {value: data, enumerable: true, writable: false, configurable: false}, ) obj } let lift = s => computed(() => s.value) type changes<'a> = {upsert: array<'a>, remove: array} type changing<'a> = {changes: unit => changes<'a>, mute: (unit => unit) => unit} type changeCounter = {mutable changed: int} let _changing = (accessor: unit => 'a, guard: option bool>): changing<'a> => { let empty: changes<'a> = {upsert: [], remove: []} let meta = switch _meta(accessor()) { | Value(m) => m | _ => raise("changing: argument is not a tilia proxy") } let root = meta.root let pending: dict> = Dict.make() let counter = proxify(root, {changed: 0}).proxy let currentMeta = ref(meta) let register = (m: meta<'a>, cb) => { let cbs = switch m.changes { | Value(cbs) => cbs | _ => { let cbs = Set.make() m.changes = Value(cbs) cbs } } Set.add(cbs, cb) cbs } let drain = () => { let upsert = [] let remove = [] Dict.forEach(pending, (v, k) => { switch v { | Value(x) => Array.push(upsert, x) | _ => Array.push(remove, k) } Dict.delete(pending, k) }) {upsert, remove} } let cb = makeCb((key, value) => { Dict.set(pending, key, value) counter.changed = counter.changed + 1 }) let currentCbs = ref(register(meta, cb)) let reregister = () => { switch _meta(accessor()) { | Value(m) if m !== currentMeta.contents => { ignore(Set.delete(currentCbs.contents, cb)) currentCbs := register(m, cb) currentMeta := m } | _ => () } } // Subscribe the current observer to [counter.changed] through the proxy. let read = () => ignore((Reflect.get(counter, "changed"): int)) let capture = switch guard { | Some(g) => () => { reregister() if !g() { empty } else { read() Dict.size(pending) > 0 ? drain() : empty } } | _ => () => { reregister() read() Dict.size(pending) > 0 ? drain() : empty } } let mute = fn => { reregister() ignore(Set.delete(currentCbs.contents, cb)) if root.lock { fn() } else { root.lock = true fn() root.lock = false flush(root) } Set.add(currentCbs.contents, cb) } {changes: capture, mute} } external identity: 'a => 'b = "%identity" let changing = (accessor, ~guard=?) => _changing(identity(accessor), guard) let tilia = _ctx.tilia let carve = _ctx.carve let observe = _ctx.observe let watch = _ctx.watch let batch = _ctx.batch // extra let signal = _ctx.signal let derived = _ctx.derived let source = _ctx.source let store = _ctx.store // internal let _observe = _ctx._observe // Opaque type for library developers let _meta: 'a => nullable<'b> = p => Reflect.get(p, metaKey)