UNPKG

rvx

Version:

A signal based rendering library

1,139 lines (1,118 loc) 24.4 kB
/*! This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/>. */ let WINDOW = []; const _capture = (context) => { return { c: context, v: context.current, }; }; class Context { constructor(defaultValue) { this.default = defaultValue; } #frame; #window; default; get current() { if (this.#window === WINDOW) { return this.#frame ?? this.default; } return this.default; } provide(value, fn, ...args) { const window = WINDOW; const parentValue = this.#frame; const parentWindow = this.#window; try { this.#window = window; window.push(this); this.#frame = value; return fn(...args); } finally { this.#frame = parentValue; window.pop(); this.#window = parentWindow; } } with(value) { return { c: this, v: value }; } static isolate(states, fn, ...args) { const parent = WINDOW; try { WINDOW = []; return Context.provide(states, fn, ...args); } finally { WINDOW = parent; } } static provide(states, fn, ...args) { const active = []; const window = WINDOW; for (let i = 0; i < states.length; i++) { const { c: context, v: value } = states[i]; active.push({ c: context, p: context.#window, v: context.#frame }); context.#window = window; context.#frame = value; window.push(context); } try { return fn(...args); } finally { for (let i = active.length - 1; i >= 0; i--) { const { c: context, p: parent, v: parentValue } = active[i]; context.#window = parent; context.#frame = parentValue; window.pop(); } } } static capture() { return WINDOW.map(_capture); } static bind(fn) { const states = Context.capture(); return ((...args) => Context.isolate(states, fn, ...args)); } } const HTML = "http://www.w3.org/1999/xhtml"; const SVG = "http://www.w3.org/2000/svg"; const MATHML = "http://www.w3.org/1998/Math/MathML"; const XMLNS = new Context(HTML); const NODE = Symbol.for("rvx:node"); const ENV = new Context(globalThis); const NOOP = () => { }; const THROW_ON_LEAK = { push(_hook) { throw new Error("G5"); }, }; const LEAK = { push() { }, }; let TEARDOWN_FRAME = THROW_ON_LEAK; let ACCESS_FRAME; function dispose(hooks) { for (let i = hooks.length - 1; i >= 0; i--) { hooks[i](); } } function capture(fn) { const parent = TEARDOWN_FRAME; const hooks = []; try { TEARDOWN_FRAME = hooks; fn(); } catch (error) { isolate(dispose, hooks); throw error; } finally { TEARDOWN_FRAME = parent; } return hooks.length === 0 ? NOOP : () => isolate(dispose, hooks); } function captureSelf(fn) { let disposed = false; let dispose = NOOP; let value; dispose = capture(() => { value = fn(() => { disposed = true; dispose(); }); }); if (disposed) { dispose(); } return value; } function leak(fn) { const parent = TEARDOWN_FRAME; try { TEARDOWN_FRAME = LEAK; return fn(); } finally { TEARDOWN_FRAME = parent; } } function teardownOnError(fn) { let value; teardown(capture(() => { value = fn(); })); return value; } function teardown(hook) { return TEARDOWN_FRAME.push(hook); } function isolate(fn, ...args) { const parentTeardownFrame = TEARDOWN_FRAME; const parentAccessFrame = ACCESS_FRAME; try { TEARDOWN_FRAME = THROW_ON_LEAK; ACCESS_FRAME = undefined; return fn(...args); } finally { TEARDOWN_FRAME = parentTeardownFrame; ACCESS_FRAME = parentAccessFrame; } } let BATCH; const _notify = (fn) => { try { fn(); } catch (error) { Promise.reject(error); } }; const _queueBatch = (fn) => BATCH.add(fn); class Signal { inert; #core = { c: 0, h: new Set(), }; #source; #root; constructor(value, source) { this.inert = value; this.#source = source; this.#root = source ? source.#root : this; } get value() { this.access(); return this.inert; } set value(value) { if (!Object.is(this.inert, value)) { this.inert = value; this.notify(); } } get source() { return this.#source; } get root() { return this.#root; } get active() { return this.#core.h.size > 0; } access() { ACCESS_FRAME?.(this.#core); } notify() { const core = this.#core; core.c++; if (core.h.size === 0) { return; } if (BATCH === undefined) { const record = Array.from(core.h); core.h.clear(); record.forEach(_notify); } else { core.h.forEach(_queueBatch); } } pipe(fn, ...args) { return fn(this, ...args); } } function $(value, source) { return new Signal(value, source); } const _unfold = (hook) => { let depth = 0; return () => { if (depth < 2) { depth++; } if (depth === 1) { try { while (depth > 0) { hook(); depth--; } } finally { depth = 0; } } }; }; const _observer = (hook) => { const signals = []; return { c: () => { for (let i = 0; i < signals.length; i++) { signals[i].h.delete(hook); } signals.length = 0; }, a: (hooks) => { signals.push(hooks); hooks.h.add(hook); }, }; }; function watch(expr, effect) { const isSignal = expr instanceof Signal; if (isSignal || typeof expr === "function") { let value; let disposed = false; let dispose = NOOP; const runExpr = isSignal ? () => expr.value : expr; const entry = _unfold(Context.bind(() => { if (disposed) { return; } clear(); isolate(dispose); dispose = capture(() => { const parent = ACCESS_FRAME; try { ACCESS_FRAME = access; value = runExpr(); if (effect) { ACCESS_FRAME = undefined; effect(value); } } finally { ACCESS_FRAME = parent; } }); })); const { c: clear, a: access } = _observer(entry); teardown(() => { disposed = true; clear(); dispose(); }); entry(); } else { effect(expr); } } function watchUpdates(expr, effect) { let first; let update = false; watch(expr, value => { if (update) { effect(value); } else { first = value; update = true; } }); return first; } function _isStale(dep) { return dep.c !== dep.s.c; } function lazy(expr) { let stale = true; let value; const deps = []; const access = signal => { deps.push({ s: signal, c: signal.c, }); }; return Context.bind(() => { const observer = ACCESS_FRAME; if (stale || (stale = deps.some(_isStale))) { const parentTeardownFrame = TEARDOWN_FRAME; try { deps.length = 0; ACCESS_FRAME = access; TEARDOWN_FRAME = THROW_ON_LEAK; value = expr(); stale = false; } finally { ACCESS_FRAME = observer; TEARDOWN_FRAME = parentTeardownFrame; if (observer) { deps.forEach(dep => observer(dep.s)); } } } else { if (observer) { deps.forEach(dep => observer(dep.s)); } } return value; }); } function _dispatch(batch) { while (batch.size > 0) { batch.forEach(notify => { batch.delete(notify); _notify(notify); }); } } function batch(fn) { if (BATCH === undefined) { const batch = new Set(); let value; try { BATCH = batch; value = fn(); _dispatch(batch); } finally { BATCH = undefined; } return value; } return fn(); } function memo(expr) { const signal = $(undefined); watch(() => signal.value = get(expr)); return () => signal.value; } function untrack(expr) { const parent = ACCESS_FRAME; try { ACCESS_FRAME = undefined; return get(expr); } finally { ACCESS_FRAME = parent; } } function isTracking() { return ACCESS_FRAME !== undefined; } function trigger(callback) { const hookFn = Context.bind(() => { clear(); isolate(_notify, callback); }); const { c: clear, a: access } = _observer(hookFn); teardown(clear); return (expr) => { clear(); const parent = ACCESS_FRAME; try { if (parent === undefined) { ACCESS_FRAME = access; } else { ACCESS_FRAME = hooks => { access(hooks); parent?.(hooks); }; } return get(expr); } finally { ACCESS_FRAME = parent; } }; } function get(expr) { if (expr instanceof Signal) { return expr.value; } if (typeof expr === "function") { return expr(); } return expr; } function map(input, mapFn) { if (input instanceof Signal) { return () => mapFn(input.value); } if (typeof input === "function") { return () => mapFn(input()); } return mapFn(input); } function createText(expr, env) { const text = env.document.createTextNode(""); watch(expr, value => text.textContent = (value ?? "")); return text; } function createMapArrayState() { const state = []; teardown(() => { for (let i = 0; i < state.length; i++) { state[i].d(); } }); return state; } function createEntry(value, index, fn) { const signal = $(index); let output; const dispose = isolate(capture, () => { output = fn(value, () => signal.value); }); return { i: value, o: output, s: signal, d: dispose, r: false, }; } function mapArrayUpdate(state, rawInput, fn) { const inputs = Array.isArray(rawInput) ? rawInput : Array.from(rawInput); let start = 0; const maxStart = Math.min(state.length, inputs.length); while (start < maxStart && Object.is(inputs[start], state[start].i)) { start++; } if (start === inputs.length && inputs.length === state.length) { return null; } const minEnd = inputs.length - maxStart + start; const lenDiff = inputs.length - state.length; let end = inputs.length - 1; while (end >= minEnd && Object.is(inputs[end], state[end - lenDiff].i)) { end--; } end++; const stateEnd = end - lenDiff; const nextState = []; if ((end - lenDiff - start) === 0) { for (let i = start; i < end; i++) { nextState[i - start] = createEntry(inputs[i], i, fn); } } else { const indexByValue = new Map(); const nextIndexByIndex = []; for (let i = end - 1; i >= start; i--) { const value = inputs[i]; const next = indexByValue.get(value); if (next !== undefined) { nextIndexByIndex[i] = next; } indexByValue.set(value, i); } for (let i = start; i < stateEnd; i++) { const instance = state[i]; const index = indexByValue.get(instance.i); if (index === undefined) { instance.d(); instance.r = true; } else { nextState[index - start] = instance; indexByValue.set(instance.i, nextIndexByIndex[index]); } } for (let i = start; i < end; i++) { const old = nextState[i - start]; if (old) { old.s.value = i; } else { nextState[i - start] = createEntry(inputs[i], i, fn); } } } const prevState = state.splice(start, stateEnd - start, ...nextState); if (stateEnd !== end) { for (let i = end; i < state.length; i++) { state[i].s.value = i; } } return { s: start, e: end, p: prevState, n: nextState, }; } function createPlaceholder(env) { return env.document.createComment("g"); } function createParent(env) { return env.document.createDocumentFragment(); } function empty(setBoundary, env) { const node = createPlaceholder(env); setBoundary(node, node); } function use(setBoundary, node, env) { if (node.firstChild === null) { empty(setBoundary, env); } else { setBoundary(node.firstChild, node.lastChild); } } function render(content) { if (content instanceof View) { return content; } return new View(setBoundary => { const env = ENV.current; if (Array.isArray(content)) { const flat = content.flat(Infinity); if (flat.length > 1) { const parent = createParent(env); for (let i = 0; i < flat.length; i++) { let part = flat[i]; if (part === null || part === undefined) { parent.appendChild(createPlaceholder(env)); } else if (typeof part === "object") { if (NODE in part) { part = part[NODE]; } if (part instanceof env.Node) { if (part.nodeType === 11 && part.childNodes.length === 0) { parent.appendChild(createPlaceholder(env)); } else { parent.appendChild(part); } } else if (part instanceof View) { part.appendTo(parent); if (i === 0) { part.setBoundaryOwner((first, _last) => setBoundary(first, undefined)); } else if (i === flat.length - 1) { part.setBoundaryOwner((_first, last) => setBoundary(undefined, last)); } } else { parent.appendChild(createText(part, env)); } } else { parent.appendChild(createText(part, env)); } } use(setBoundary, parent, env); return; } content = flat[0]; } if (content === null || content === undefined) { empty(setBoundary, env); } else if (typeof content === "object") { if (NODE in content) { content = content[NODE]; } if (content instanceof env.Node) { if (content.nodeType === 11) { use(setBoundary, content, env); } else { setBoundary(content, content); } } else if (content instanceof View) { setBoundary(content.first, content.last); content.setBoundaryOwner(setBoundary); } else { const text = createText(content, env); setBoundary(text, text); } } else { const text = createText(content, env); setBoundary(text, text); } }); } function mount(parent, content) { const view = render(content); view.appendTo(parent); teardown(() => view.detach()); return view; } class View { #first; #last; #owner; constructor(init) { init((first, last) => { if (first) { this.#first = first; } if (last) { this.#last = last; } this.#owner?.(this.#first, this.#last); }, this); if (!this.#first || !this.#last) { throw new Error("G1"); } } get first() { return this.#first; } get last() { return this.#last; } get parent() { return this.#first?.parentNode ?? undefined; } setBoundaryOwner(owner) { if (this.#owner !== undefined) { throw new Error("G2"); } this.#owner = owner; teardown(() => this.#owner = undefined); } appendTo(parent) { let node = this.#first; const last = this.#last; for (;;) { const next = node.nextSibling; parent.appendChild(node); if (node === last) { break; } node = next; } } insertBefore(parent, ref) { if (ref === null) { return this.appendTo(parent); } let node = this.#first; const last = this.#last; for (;;) { const next = node.nextSibling; parent.insertBefore(node, ref); if (node === last) { break; } node = next; } } detach() { const first = this.#first; const last = this.#last; if (first === last) { first.parentNode?.removeChild(first); return first; } else { const fragment = ENV.current.document.createDocumentFragment(); this.appendTo(fragment); return fragment; } } } function* viewNodes(view) { let node = view.first; for (;;) { const next = node.nextSibling; yield node; if (node === view.last) { break; } node = next; } } const _nestDefault = ((component) => component?.()); function nest(expr, component = _nestDefault) { return new View((setBoundary, self) => { watch(expr, value => { const last = self.last; const parent = last?.parentNode; let view; if (parent) { const anchor = last.nextSibling; self.detach(); view = render(component(value)); view.insertBefore(parent, anchor); } else { view = render(component(value)); } setBoundary(view.first, view.last); view.setBoundaryOwner(setBoundary); }); }); } function when(condition, truthy, falsy) { return nest(memo(condition), value => value ? truthy(value) : falsy?.()); } function forEach(each, component) { return new View(setBoundary => { const env = ENV.current; const first = createPlaceholder(env); setBoundary(first, first); const mapFn = (input, index) => render(component(input, index)); const state = createMapArrayState(); watch(() => { const update = mapArrayUpdate(state, get(each), mapFn); if (update !== null) { let parent = first.parentNode; if (parent === null) { parent = createParent(env); parent.appendChild(first); } for (let i = 0; i < update.p.length; i++) { const entry = update.p[i]; if (entry.r) { entry.o.detach(); } } let prev = update.s > 0 ? state[update.s - 1].o.last : first; for (let i = 0; i < update.n.length; i++) { const view = update.n[i].o; if (prev.nextSibling !== view.first) { view.insertBefore(parent, prev.nextSibling); } prev = view.last; } if (update.e === state.length) { setBoundary(undefined, prev); } } }); }); } function indexEach(each, component) { return new View((setBoundary, self) => { const env = ENV.current; const state = []; const first = createPlaceholder(env); setBoundary(first, first); teardown(() => { for (let i = state.length - 1; i >= 0; i--) { state[i].d(); } }); watch(() => { let parent = first.parentNode; if (!parent) { parent = createParent(env); parent.appendChild(first); } let index = 0; for (const value of get(each)) { if (index < state.length) { const instance = state[index]; instance.i.value = value; } else { const signal = $(value); let view; const dispose = isolate(capture, () => { view = render(component(() => signal.value, index)); }); state.push({ i: signal, v: view, d: dispose, }); const prev = index > 0 ? state[index - 1].v.last : first; view.insertBefore(parent, prev.nextSibling); } index++; } while (state.length > index) { const instance = state.pop(); instance.d(); instance.v.detach(); } const last = state.length > 0 ? state[state.length - 1].v.last : first; if (self.last !== last) { setBoundary(undefined, last); } }); }); } class MovableView { #view; #target = $(); constructor(view) { this.#view = view; } move = () => { this.#target.value = undefined; const target = this.#target = $(this.#view); return nest(target, v => v); }; detach() { this.#target.value = undefined; } } function movable(content) { return new MovableView(render(content)); } function attachWhen(condition, content) { return nest(condition, value => value ? content : undefined); } function appendContent(node, content, env) { if (content === null || content === undefined) { return; } if (Array.isArray(content)) { for (let i = 0; i < content.length; i++) { appendContent(node, content[i], env); } } else if (content instanceof env.Node) { node.appendChild(content); } else if (content instanceof View) { content.appendTo(node); } else if (typeof content === "object" && NODE in content) { node.appendChild(content[NODE]); } else { node.appendChild(createText(content, env)); } } function setAttr(elem, name, value) { watch(value, value => { if (value === null || value === undefined || value === false) { elem.removeAttribute(name); } else { elem.setAttribute(name, value === true ? "" : value); } }); } class ClassBucket { #target; #entries = []; #removeQueue = []; #addQueue = []; constructor(target) { this.#target = target; } a(token) { const entries = this.#entries; teardown(() => { const removeQueue = this.#removeQueue; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.t === token) { if (--entry.c === 0) { entries.splice(i, 1); removeQueue.push(token); } return; } } }); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.t === token) { entry.c++; return; } } entries.push({ t: token, c: 1 }); this.#addQueue.push(token); } f() { const target = this.#target; const removeQueue = this.#removeQueue; const addQueue = this.#addQueue; if (removeQueue.length > 0) { target.classList.remove(...removeQueue); removeQueue.length = 0; } if (addQueue.length > 0) { if (target.hasAttribute("class")) { target.classList.add(...addQueue); } else { target.setAttribute("class", addQueue.join(" ")); } addQueue.length = 0; } } } function watchClass(value, bucket, flush) { watch(value, value => { if (typeof value === "string") { bucket.a(value); } else if (value) { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { watchClass(value[i], bucket, false); } } else { for (const token in value) { watch(value[token], enable => { if (enable) { bucket.a(token); } if (flush) { bucket.f(); } }); } } } if (flush) { bucket.f(); } else { flush = true; } }); } function setClass(elem, value) { watchClass(value, new ClassBucket(elem), true); } function watchStyle(value, handler) { watch(value, value => { if (Array.isArray(value)) { const overrides = []; for (let i = value.length - 1; i >= 0; i--) { const self = []; overrides[i] = self; watchStyle(value[i], (name, value) => { if (!self.includes(name)) { self.push(name); } for (let o = i + 1; o < overrides.length; o++) { if (overrides[o].includes(name)) { return; } } handler(name, value); }); } } else if (value) { for (const name in value) { watch(value[name], value => handler(name, value)); } } }); } function setStyle(elem, value) { const style = elem.style; watchStyle(value, (name, value) => style.setProperty(name, value)); } class ElementBuilder { #env = ENV.current; elem; get [NODE]() { return this.elem; } constructor(elem) { this.elem = elem; } on(type, listener, options) { const bound = Context.bind(listener); this.elem.addEventListener(type, event => isolate(bound, event), options); return this; } style(value) { setStyle(this.elem, value); return this; } class(value) { setClass(this.elem, value); return this; } set(name, value) { setAttr(this.elem, name, value); return this; } prop(name, value) { watch(value, value => this.elem[name] = value); return this; } append(...content) { appendContent(this.elem, content, this.#env); return this; } } function e(tagName) { return new ElementBuilder(ENV.current.document.createElementNS(XMLNS.current, tagName)); } class Emitter { #listeners = new Set(); event = listener => { this.#listeners.add(listener); teardown(() => this.#listeners.delete(listener)); }; emit = (...args) => { this.#listeners.forEach(fn => fn(...args)); }; } function mapArray(inputs, fn) { const state = createMapArrayState(); const output = $([]); watch(() => { const update = mapArrayUpdate(state, get(inputs), fn); if (update !== null) { untrack(output).splice(update.s, update.p.length, ...update.n.map(s => s.o)); output.notify(); } }); return () => output.value; } function override(target) { return new ElementBuilder(NODE in target ? target[NODE] : target); } const NEXT_ID = { value: 0 }; function uniqueId() { const next = NEXT_ID.value; if (typeof next === "number" && next >= Number.MAX_SAFE_INTEGER) { NEXT_ID.value = BigInt(NEXT_ID.value) + 1n; } else { NEXT_ID.value++; } return "rvx_" + String(next); } function useUniqueId(component) { return component(uniqueId()); } function UseUniqueId(props) { return props.children(uniqueId()); } const IDS = new WeakMap(); function uniqueIdFor(target) { let id = IDS.get(target); if (id === undefined) { id = uniqueId(); IDS.set(target, id); } return id; } export { $, Context, ENV, ElementBuilder, Emitter, HTML, MATHML, MovableView, NODE, SVG, Signal, UseUniqueId, View, XMLNS, attachWhen, batch, capture, captureSelf, e, forEach, get, indexEach, isTracking, isolate, lazy, leak, map, mapArray, memo, mount, movable, nest, override, render, teardown, teardownOnError, trigger, uniqueId, uniqueIdFor, untrack, useUniqueId, viewNodes, watch, watchUpdates, when };