rvx
Version:
A signal based rendering library
1,139 lines (1,118 loc) • 24.4 kB
JavaScript
/*!
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 };