rvx
Version:
A signal based rendering library
1,103 lines (1,079 loc) • 24.4 kB
JavaScript
/*!
MIT License
Copyright (c) 2025 Max J. Polster
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
const LEAK = {
push(hook) {
LEAK_HOOK?.(hook);
},
};
const TEARDOWN_STACK = [LEAK];
let LEAK_HOOK = undefined;
const TRACKING_STACK = [true];
const ACCESS_STACK = [];
const CONTEXT_WINDOWS = [[]];
function useStack(stack, frame, fn) {
try {
stack.push(frame);
return fn();
}
finally {
stack.pop();
}
}
function onLeak(hook) {
if (LEAK_HOOK !== undefined) {
throw new Error("G4");
}
LEAK_HOOK = hook;
}
const _capture = (context) => {
return {
context: context,
value: context.current,
};
};
class Context {
constructor(defaultValue) {
this.default = defaultValue;
}
#stack = [];
#windowId = 0;
default;
get current() {
if (this.#windowId === CONTEXT_WINDOWS.length) {
const stack = this.#stack;
return stack[stack.length - 1] ?? this.default;
}
return this.default;
}
inject(value, fn, ...args) {
const window = CONTEXT_WINDOWS[CONTEXT_WINDOWS.length - 1];
const stack = this.#stack;
const parent = this.#windowId;
try {
this.#windowId = CONTEXT_WINDOWS.length;
window.push(this);
stack.push(value);
return fn(...args);
}
finally {
stack.pop();
window.pop();
this.#windowId = parent;
}
}
with(value) {
return { context: this, value };
}
static window(states, fn, ...args) {
try {
CONTEXT_WINDOWS.push([]);
return Context.inject(states, fn, ...args);
}
finally {
CONTEXT_WINDOWS.pop();
}
}
static inject(states, fn, ...args) {
const active = [];
const windowId = CONTEXT_WINDOWS.length;
const window = CONTEXT_WINDOWS[windowId - 1];
for (let i = 0; i < states.length; i++) {
const { context, value } = states[i];
active.push({ c: context, p: context.#windowId });
context.#windowId = windowId;
context.#stack.push(value);
window.push(context);
}
try {
return fn(...args);
}
finally {
for (let i = active.length - 1; i >= 0; i--) {
const { c: context, p: parent } = active[i];
context.#windowId = parent;
context.#stack.pop();
window.pop();
}
}
}
static capture() {
return CONTEXT_WINDOWS[CONTEXT_WINDOWS.length - 1].map(_capture);
}
static wrap(fn) {
const states = Context.capture();
return ((...args) => Context.window(states, fn, ...args));
}
}
function Inject(props) {
if ("context" in props) {
return props.context.inject(props.value, props.children);
}
return Context.inject(props.states, props.children);
}
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 = () => { };
function isolate(fn, ...args) {
try {
TEARDOWN_STACK.push(LEAK);
ACCESS_STACK.push(undefined);
TRACKING_STACK.push(true);
return fn(...args);
}
finally {
TEARDOWN_STACK.pop();
ACCESS_STACK.pop();
TRACKING_STACK.pop();
}
}
function dispose(hooks) {
for (let i = hooks.length - 1; i >= 0; i--) {
hooks[i]();
}
}
function capture(fn) {
const hooks = [];
try {
useStack(TEARDOWN_STACK, hooks, fn);
}
catch (error) {
isolate(dispose, hooks);
throw error;
}
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 uncapture(fn) {
return useStack(TEARDOWN_STACK, undefined, fn);
}
function teardownOnError(fn) {
let value;
teardown(capture(() => {
value = fn();
}));
return value;
}
function teardown(hook) {
const length = TEARDOWN_STACK.length;
if (length > 0) {
TEARDOWN_STACK[length - 1]?.push(hook);
}
}
let BATCH;
const notify = (fn) => fn();
const queueBatch = (fn) => BATCH.add(fn);
class Signal {
#value;
#hooks = new Set();
#source;
#root;
constructor(value, source) {
this.#value = value;
this.#source = source;
this.#root = source ? source.#root : this;
}
get value() {
this.access();
return this.#value;
}
set value(value) {
if (!Object.is(this.#value, value)) {
this.#value = value;
this.notify();
}
}
get source() {
return this.#source;
}
get root() {
return this.#root;
}
update(fn) {
if (fn(this.#value) !== false) {
this.notify();
}
}
get active() {
return this.#hooks.size > 0;
}
access() {
if (TRACKING_STACK[TRACKING_STACK.length - 1]) {
const length = ACCESS_STACK.length;
if (length > 0) {
ACCESS_STACK[length - 1]?.(this.#hooks);
}
}
}
notify() {
const hooks = this.#hooks;
if (hooks.size === 0) {
return;
}
if (BATCH === undefined) {
const record = Array.from(hooks);
hooks.clear();
record.forEach(notify);
}
else {
hooks.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].delete(hook);
}
signals.length = 0;
},
a: (hooks) => {
signals.push(hooks);
hooks.add(hook);
},
};
};
const _access = (frame, fn) => {
try {
ACCESS_STACK.push(frame);
TRACKING_STACK.push(true);
return fn();
}
finally {
ACCESS_STACK.pop();
TRACKING_STACK.pop();
}
};
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.wrap(() => {
if (disposed) {
return;
}
clear();
isolate(dispose);
dispose = capture(() => {
value = _access(access, runExpr);
if (effect) {
_access(undefined, () => effect(value));
}
});
}));
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 dispatch(batch) {
while (batch.size > 0) {
try {
batch.forEach(notify => {
batch.delete(notify);
notify();
});
}
finally {
dispatch(batch);
}
}
}
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(fn) {
return useStack(TRACKING_STACK, false, fn);
}
function track(fn) {
return useStack(TRACKING_STACK, true, fn);
}
function isTracking() {
return TRACKING_STACK[TRACKING_STACK.length - 1] && ACCESS_STACK[ACCESS_STACK.length - 1] !== undefined;
}
function trigger(callback) {
const hookFn = Context.wrap(() => {
clear();
isolate(callback);
});
const { c: clear, a: access } = _observer(hookFn);
teardown(clear);
return (expr) => {
clear();
try {
const outerLength = ACCESS_STACK.length;
if (outerLength > 0) {
const outer = ACCESS_STACK[outerLength - 1];
ACCESS_STACK.push(hooks => {
access(hooks);
outer?.(hooks);
});
}
else {
ACCESS_STACK.push(access);
}
return get(expr);
}
finally {
ACCESS_STACK.pop();
}
};
}
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 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 Nest(props) {
return nest(props.watch, props.children);
}
function when(condition, truthy, falsy) {
return nest(memo(condition), value => value ? truthy(value) : falsy?.());
}
function Show(props) {
return when(props.when, props.children, props.else);
}
function forEach(each, component) {
return new View((setBoundary, self) => {
function detach(instances) {
for (let i = 0; i < instances.length; i++) {
instances[i].v.detach();
}
}
const env = ENV.current;
let cycle = 0;
const instances = [];
const instanceMap = new Map();
const first = createPlaceholder(env);
setBoundary(first, first);
teardown(() => {
for (let i = 0; i < instances.length; i++) {
instances[i].d();
}
});
watch(() => {
let parent = self.parent;
if (!parent) {
parent = createParent(env);
parent.appendChild(first);
}
let index = 0;
let last = first;
try {
for (const value of get(each)) {
let instance = instances[index];
if (instance && Object.is(instance.u, value)) {
instance.c = cycle;
instance.i.value = index;
last = instance.v.last;
index++;
}
else {
instance = instanceMap.get(value);
if (instance === undefined) {
const instance = {
u: value,
c: cycle,
i: $(index),
d: undefined,
v: undefined,
};
instance.d = isolate(capture, () => {
instance.v = render(component(value, () => instance.i.value));
instance.v.setBoundaryOwner((_, last) => {
if (instances[instances.length - 1] === instance && instance.c === cycle) {
setBoundary(undefined, last);
}
});
});
instance.v.insertBefore(parent, last.nextSibling);
instances.splice(index, 0, instance);
instanceMap.set(value, instance);
last = instance.v.last;
index++;
}
else if (instance.c !== cycle) {
instance.i.value = index;
instance.c = cycle;
const currentIndex = instances.indexOf(instance, index);
if (currentIndex < 0) {
detach(instances.splice(index, instances.length - index, instance));
instance.v.insertBefore(parent, last.nextSibling);
}
else {
detach(instances.splice(index, currentIndex - index));
}
last = instance.v.last;
index++;
}
}
}
}
finally {
if (instances.length > index) {
detach(instances.splice(index));
}
for (const [value, instance] of instanceMap) {
if (instance.c !== cycle) {
instanceMap.delete(value);
instance.v.detach();
instance.d();
}
}
cycle = (cycle + 1) | 0;
setBoundary(undefined, last);
}
});
});
}
function For(props) {
return forEach(props.each, props.children);
}
function indexEach(each, component) {
return new View((setBoundary, self) => {
const env = ENV.current;
const first = createPlaceholder(env);
setBoundary(first, first);
const instances = [];
teardown(() => {
for (let i = 0; i < instances.length; i++) {
instances[i].d();
}
});
watch(() => {
let parent = self.parent;
if (!parent) {
parent = createParent(env);
parent.appendChild(first);
}
let index = 0;
let last = first;
try {
for (const value of get(each)) {
if (index < instances.length) {
const current = instances[index];
if (Object.is(current.u, value)) {
last = current.v.last;
index++;
continue;
}
current.v.detach();
current.d();
current.d = NOOP;
}
const instance = {
u: value,
d: undefined,
v: undefined,
};
instance.d = isolate(capture, () => {
instance.v = render(component(value, index));
instance.v.setBoundaryOwner((_, last) => {
if (instances[instances.length - 1] === instance) {
setBoundary(undefined, last);
}
});
});
instance.v.insertBefore(parent, last.nextSibling);
instances[index] = instance;
last = instance.v.last;
index++;
}
}
finally {
if (instances.length > index) {
for (let i = index; i < instances.length; i++) {
const instance = instances[i];
instance.v.detach();
instance.d();
}
instances.length = index;
}
setBoundary(undefined, last);
}
});
});
}
function Index(props) {
return indexEach(props.each, props.children);
}
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 Attach(props) {
return attachWhen(props.when, props.children);
}
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 overwrites = [];
for (let i = value.length - 1; i >= 0; i--) {
const self = [];
overwrites[i] = self;
watchStyle(value[i], (name, value) => {
if (!self.includes(name)) {
self.push(name);
}
for (let o = i + 1; o < overwrites.length; o++) {
if (overwrites[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 ? String(value) : null));
}
class ElementBuilder {
#env = ENV.current;
elem;
get [NODE]() {
return this.elem;
}
constructor(elem) {
this.elem = elem;
}
on(type, listener, options) {
const wrapped = Context.wrap(listener);
this.elem.addEventListener(type, event => isolate(wrapped, 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));
};
}
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 { $, Attach, Context, ENV, ElementBuilder, Emitter, For, HTML, Index, Inject, MATHML, MovableView, NODE, Nest, SVG, Show, Signal, UseUniqueId, View, XMLNS, attachWhen, batch, capture, captureSelf, e, forEach, get, indexEach, isTracking, isolate, map, memo, mount, movable, nest, onLeak, render, teardown, teardownOnError, track, trigger, uncapture, uniqueId, uniqueIdFor, untrack, useUniqueId, viewNodes, watch, watchUpdates, when };