import { ReverseSortedSet } from "./helpers/reverseSortedSet.js"; import type { ReverseSortedSetPointer } from "./helpers/reverseSortedSet.js"; /* * QueueRunner * * `queue()`d runners are executed on the next timer tick, by order of their * `prio` values. */ interface QueueRunner { prio: number; // Higher values have higher priority queueRun(): void; [ptr: ReverseSortedSetPointer]: QueueRunner; } let sortedQueue: ReverseSortedSet | undefined; // When set, a runQueue is scheduled or currently running. let runQueueDepth = 0; // Incremented when a queue event causes another queue event to be added. Reset when queue is empty. Throw when >= 42 to break (infinite) recursion. let freezeCount = 0; // While > 0 (see `freeze()`), `runQueue` is paused and updates accumulate. let topRedrawScope: Scope | undefined; // The scope that triggered the current redraw. Elements drawn at this scope level may trigger 'create' animations. // During a teardown (a scope being cleaned), this holds the element that *survives* that teardown: // the element whose child content is being removed, but which itself stays in the DOM. Value-restoring // cleaners (attributes/classes/styles/properties/event listeners) only need to run for this element; // for any element nested below it, the element itself is being removed from the DOM, so reverting its // attributes would be wasted work. Analogous to how `removeNodes` only detaches the top-level node. let survivingEl: Element | undefined; // Side-effects that a scope applied to its *own* element and that must be undone when the scope is // cleaned (so the element returns to its pre-scope state before a re-render, or stays clean when the // scope goes away). Rather than allocating a capturing closure per side-effect, each ContentScope // keeps a single flat `sideEffects` array of (type, key, value) triplets. All entries target the // scope's own element, so whether they need undoing can be decided once per scope (see `delete`). const enum SideEffect { Class = 0, // key: class name value: whether the class was present before (boolean) Style = 1, // key: camelCase style prop or `--custom-prop` value: previous style value (string) Prop = 2, // key: DOM property name value: previous property value Attr = 3, // key: attribute name value: previous attribute value (string | null) Event = 4, // key: event name value: the listener function to remove } function recordSideEffect(scope: ContentScope, type: SideEffect, key: any, value: any) { (scope.sideEffects ||= []).push(type, key, value); } // Undo the side-effects in reverse (LIFO) order, restoring the element to its pre-scope state. function undoSideEffects(el: any, se: any[]) { for (let i = se.length - 3; i >= 0; i -= 3) { const key = se[i + 1]; const value = se[i + 2]; switch (se[i] as SideEffect) { case SideEffect.Class: value ? el.classList.add(key) : el.classList.remove(key); break; case SideEffect.Style: if (key[0] === "-" && key[1] === "-") value ? el.style.setProperty(key, value) : el.style.removeProperty(key); else el.style[key] = value == null ? "" : value; break; case SideEffect.Prop: el[key] = value; break; case SideEffect.Attr: value == null ? el.removeAttribute(key) : el.setAttribute(key, value); break; case SideEffect.Event: el.removeEventListener(key, value); break; } } } /** @internal */ export type TargetType = any[] | { [key: string | symbol]: any } | Map | Set; function queue(runner: QueueRunner) { if (!sortedQueue) { sortedQueue = new ReverseSortedSet("prio"); queueMicrotask(runQueue); } else if (!(runQueueDepth & 1)) { runQueueDepth++; // Make it uneven if (runQueueDepth > 98) { throw new Error("Too many recursive updates from observes"); } } sortedQueue.add(runner); } /** * Forces the immediate and synchronous execution of all pending reactive updates. * * Normally, changes to observed data sources (like proxied objects or arrays) * are processed asynchronously in a batch after a brief timeout (0ms). This function * allows you to bypass the timeout and process the update queue immediately. * * This can be useful in specific scenarios where you need the DOM to be updated * synchronously. * * This function is re-entrant, meaning it is safe to call `runQueue` from within * a function that is itself being executed as part of an update cycle triggered * by a previous (or the same) `runQueue` call. * * @example * ```typescript * const $data = A.proxy("before"); * * A('#', $data); * console.log(1, document.body.innerHTML); // before * * // Make an update that should cause the DOM to change. * $data.value = "after"; * * // Normally, the DOM update would happen after a timeout. * // But this causes an immediate update: * A.runQueue(); * * console.log(2, document.body.innerHTML); // after * ``` */ export function runQueue(): void { if (freezeCount) return; // Paused by `freeze()`; updates stay queued until thawed. let time = Date.now(); // Whoever triggered this flush may be inside a `peek()` — for instance // `route.go()`, which runs the queue synchronously. That has nothing to do // with the scopes we're about to re-run: if they were to observe `peeking` // they would silently register no subscriptions at all and never update // again. Suspend it for the duration of the flush. const wasPeeking = peeking; peeking = 0; try { while (sortedQueue) { const runner = sortedQueue.fetchLast(); if (!runner) break; if (runQueueDepth & 1) runQueueDepth++; // Make it even runner.queueRun(); } } finally { peeking = wasPeeking; } sortedQueue = undefined; runQueueDepth = 0; time = Date.now() - time; if (time > 9) console.debug(`Aberdeen queue took ${time}ms`); } /** * Pause processing of reactive updates until the returned *thaw* function is called. * * While frozen, changes to observed data still accumulate, but no re-renders run. Freezes * stack: if there are multiple outstanding freezes, redraws resume only once the last one is * thawed. This is useful to batch an async burst of changes into a single update pass, or to * hold the UI steady (e.g. the dev tools use it for "freeze redraws"). * * @returns A function that releases this freeze. Calling it more than once has no effect. * * @example * ```typescript * const thaw = A.freeze(); * // ...make many changes without intermediate redraws... * thaw(); // redraws run now (if no other freezes remain) * ``` */ export function freeze(): () => void { freezeCount++; let released = false; return () => { if (released) return; released = true; if (!--freezeCount) runQueue(); }; } /** * A sort key, as used by {@link onEach}, is a value that determines the order of items. It can * be a number, string, or an array of numbers/strings. The sort key is used to sort items * based on their values. The sort key can also be `undefined`, which indicates that the item * should be ignored. * @internal */ export type SortKeyType = number | string | Array | undefined | void; /** * Given an array of (possibly fractional) numbers or strings, this function returns a string * that sorts by natural number ordering. */ function arrayToStr(parts: (number | string)[]): string { let result = ''; for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (typeof part === "string") { result += `${part}\x01`; // end-of-string continue; } if (!Number.isFinite(part)) { throw new Error("onEach() sort key must be a finite number, string or an array of such"); } // Split the number into an integer part and a positive fraction. Rounding *down* makes // the fraction positive for negative numbers as well (-2.5 becomes -3 + 0.5), which is // what makes the lexicographic ordering below work out for both signs. let num = Math.floor(part); let frac = part - num; const negative = num < 0; if (negative) num = -num; let digits = ""; while (num > 0) { /* * We're reserving a few character codes: * 0 - for compatibility * 1 - separator between array items * 65535 - for compatibility */ digits = String.fromCharCode( negative ? 65534 - (num % 65533) : 2 + (num % 65533), ) + digits; num = Math.floor(num / 65533); } // Prefix the number of digits, counting down from 128 for negative and up for positive result += String.fromCharCode(128 + (negative ? -digits.length : digits.length)) + digits; // Fraction digits, base 65533, most significant first. As an omitted digit sorts // before any digit character, a whole number sorts before the same number with a // fraction. Four digits (64 bits) exceed double precision; the clamp catches the // rounding edge where a fraction that approaches 1 would yield digit 65533. for (let j = 0; frac > 0 && j < 4; j++) { frac *= 65533; const digit = Math.min(Math.floor(frac), 65532); frac -= digit; result += String.fromCharCode(2 + digit); } // A number followed by another part gets terminated by \x01, sorting below any // fraction digit, so that e.g. [2, "x"] sorts before [2.5, "x"]. if (i < parts.length - 1) result += "\x01"; } return result; } /** * Normalizes a sort key (as returned by a `makeSortKey` function, or an item's default key) * to the string form used for ordering item scopes, or null/undefined when the item should * not be shown. */ function normalizeSortKey(sortKey: SortKeyType | null): string | null | undefined { if (sortKey instanceof Array) return arrayToStr(sortKey); if (typeof sortKey !== "string" && sortKey != null) return arrayToStr([sortKey]); return sortKey as string | null | undefined; // `void` only exists in the type declaration } /** * Creates a new string that has the opposite sort order compared to the input string. * * This is achieved by flipping the bits of each character code in the input string. * The resulting string is intended for use as a sort key, particularly with the * `makeKey` function in {@link onEach}, to achieve a descending sort order. * * **Warning:** The output string will likely contain non-printable characters or * appear as gibberish and should not be displayed to the user. * * @example * ```typescript * const $users = A.proxy([ * { id: 1, name: 'Charlie', score: 95 }, * { id: 2, name: 'Alice', score: 100 }, * { id: 3, name: 'Bob', score: 90 }, * ]); * * A.onEach($users, ($user) => { * A(`p#${$user.name}: ${$user.score}`); * }, ($user) => A.invertString($user.name)); // Reverse alphabetic order * ``` * * @param input The string whose sort order needs to be inverted. * @returns A new string that will sort in the reverse order of the input string. * @see {@link onEach} for usage with sorting. */ export function invertString(input: string): string { let result = ""; for (let i = 0; i < input.length; i++) { result += String.fromCodePoint(65535 - input.charCodeAt(i)); } return result; } // Each new scope gets a lower prio than all scopes before it, by decrementing // this counter. let lastPrio = 0; abstract class Scope implements QueueRunner { // Scopes are to be handled in creation order. This will make sure that parents are // handled before their children (as they should), and observes are executed in the // order of the source code. prio: number = --lastPrio; [ptr: ReverseSortedSetPointer]: this; abstract onChange(target: TargetType, index: any, newData: any, oldData: any): void; abstract queueRun(): void; abstract getLastNode(): Node | undefined; abstract getPrecedingNode(): Node | undefined; abstract delete(): void; remove() { // Remove any nodes const lastNode = this.getLastNode(); if (lastNode) removeNodes(lastNode, this.getPrecedingNode()); // Run any cleaners. Our own element survives this teardown (we only removed our child // content above), so value-restoring cleaners registered for it should still run. const savedSurvivingEl = survivingEl; survivingEl = (this as any).el; this.delete(); survivingEl = savedSurvivingEl; } // toString(): string { // return `${this.constructor.name}` // } } /** * Execute a function once, after all currently scheduled jobs are completed. */ class DelayedOneTimeRunner implements QueueRunner { prio: number = --lastPrio; [ptr: ReverseSortedSetPointer]: this; constructor( public queueRun: () => void ) { queue(this); } } /** * All Scopes that can hold nodes and subscopes, including `SimpleScope` and `OnEachItemScope` * but *not* `OnEachScope`, are `ContentScope`s. */ abstract class ContentScope extends Scope { // The list of clean functions to be called when this scope is cleaned. These can // be for child scopes, subscriptions as well as `clean(..)` hooks. cleaners: Array<{ delete: (scope: Scope) => void } | (() => void)>; abstract svg: boolean; abstract el: Element; // Flat (type, key, value) triplets describing side-effects applied to our own element; see SideEffect. sideEffects: any[] | undefined; private changes: undefined | Map>; // target => (index => oldData) constructor( cleaners: Array<{ delete: (scope: Scope) => void } | (() => void)> = [], ) { super(); this.cleaners = cleaners; } lastChild: Node | Scope | undefined; // Should be subclassed in most cases.. redraw() {} getLastNode(): Node | undefined { return findLastNodeInPrevSiblings(this.lastChild); } /** * Call cleaners and make sure the scope is not queued. * It is called `delete`, so that the list of cleaners can also contain `Set`s. */ delete(/* ignore observer argument */) { // Run cleaners in reverse (LIFO) order. Child scopes live here too, so a nested scope that // shares our element gets to undo its (more recent) side-effects before we undo ours below. const cleaners = this.cleaners; for (let i = cleaners.length - 1; i >= 0; i--) { const cleaner = cleaners[i]; if (typeof cleaner === "function") cleaner(); else cleaner.delete(this); // pass in observer argument, in case `cleaner` is a `Set` } this.cleaners.length = 0; // Undo the side-effects we applied to our own element, in reverse (LIFO) order so the original // pre-scope value is restored. Only do this when our element survives the teardown; if it's // being removed from the DOM, reverting its attributes/classes/etc would be wasted work. const se = this.sideEffects; if (se) { if (this.el === survivingEl) undoSideEffects(this.el, se); this.sideEffects = undefined; } sortedQueue?.remove(this); // This is very fast and O(1) when not queued // To prepare for a redraw or to help GC when we're being removed: this.lastChild = undefined; } onChange(target: TargetType, index: any, newData: any, oldData: any): void { if (!this.changes) { this.changes = new Map(); queue(this); dev?.schedule(this, index, oldData, newData, new Error()); } let targetDelta = this.changes.get(target); if (!targetDelta) { targetDelta = new Map(); this.changes.set(target, targetDelta); } if (targetDelta.has(index)) { // Already changed before, keep original oldData // Unless it changed back to original value if (targetDelta.get(index) === newData) targetDelta.delete(index); } else { targetDelta.set(index, oldData); } } fetchHasChanges(): boolean { if (!this.changes) return false; for(const targetDelta of this.changes.values()) { if (targetDelta.size > 0) { delete this.changes; return true; } } delete this.changes; return false; } queueRun() { if (!this.fetchHasChanges()) return; this.remove(); topRedrawScope = this; this.redraw(); topRedrawScope = undefined; } getInsertAfterNode() { return this.getLastNode() || this.getPrecedingNode(); } getChildPrevSibling() { return this.lastChild; } } class ChainedScope extends ContentScope { // The node or scope right before this scope that has the same `parentElement`. public prevSibling: Node | Scope | undefined; constructor( // The parent DOM element we'll add our child nodes to. public el: Element, // Whether this scope is within an SVG namespace context public svg: boolean, // When true, we share our 'cleaners' list with the parent scope. useParentCleaners = false, ) { super(useParentCleaners ? currentScope.cleaners : []); if (el === currentScope.el) { // If `currentScope` is not actually a ChainedScope, prevSibling will be undefined, as intended this.prevSibling = currentScope.getChildPrevSibling(); currentScope.lastChild = this; } else { this.prevSibling = el.lastChild || undefined; } // We're always adding ourselve as a cleaner, in order to run our own cleaners // and to remove ourselve from the queue (if we happen to be in there). if (!useParentCleaners) currentScope.cleaners.push(this); // Covers RegularScope, ResultScope and SetArgScope; the enclosing render scope is the parent. dev?.create(this, currentScope, new Error()); } getPrecedingNode(): Node | undefined { return findLastNodeInPrevSiblings(this.prevSibling); } getChildPrevSibling() { return this.lastChild || this.prevSibling; } } /** * @internal * A `RegularScope` is created with a `render` function that is run initially, * and again when any of the `Store`s that this function reads are changed. Any * DOM elements that is given a `render` function for its contents has its own scope. * The `Scope` manages the position in the DOM tree elements created by `render` * are inserted at. Before a rerender, all previously created elements are removed * and the `clean` functions for the scope and all sub-scopes are called. */ class RegularScope extends ChainedScope { constructor( el: Element, svg: boolean, // The function that will be reactively called. Elements it creates using `$` are // added to the appropriate position within `parentElement`. public renderer: () => any, ) { super(el, svg); // Do the initial run this.redraw(); } redraw() { const savedScope = currentScope; currentScope = this; dev?.render(this); try { this.renderer(); } catch (e) { // Throw the error async, so the rest of the rendering can continue handleError(e, true); } currentScope = savedScope; } } class RootScope extends ContentScope { el = document.body; svg = false; getPrecedingNode(): Node | undefined { return undefined; } } class MountScope extends ContentScope { svg: boolean; constructor( // The parent DOM element we'll add our child nodes to public el: Element, // The function that public renderer: () => any, ) { super(); this.svg = el.namespaceURI === 'http://www.w3.org/2000/svg'; // Register before the first redraw so its render is attributed to this scope. dev?.create(this, currentScope, new Error()); const oldTopRedrawScope = topRedrawScope; topRedrawScope = this; this.redraw(); topRedrawScope = oldTopRedrawScope; currentScope.cleaners.push(this); } redraw() { RegularScope.prototype.redraw.call(this); } getPrecedingNode(): Node | undefined { return undefined; } delete() { // We can't rely on our parent scope to remove all our nodes for us, as our parent // probably has a totally different `parentElement`. Therefore, our `delete()` does // what `_remove()` does for regular scopes. removeNodes(this.getLastNode(), this.getPrecedingNode()); // Our mount element survives; reset the surviving element to it for our subtree's cleaners, // as our `el` differs from that of the (possibly removing) parent scope cascade. const savedSurvivingEl = survivingEl; survivingEl = this.el; super.delete(); survivingEl = savedSurvivingEl; } remove() { this.delete(); } } // Remove node and all its preceding siblings (uptil and excluding preNode) // from the DOM, using onDestroy if applicable. function removeNodes( node: Node | null | undefined, preNode: Node | null | undefined, ) { while (node && node !== preNode) { const prevNode: Node | null = node.previousSibling; const onDestroy = onDestroyMap.get(node); if (onDestroy && node instanceof Element) { if (onDestroy !== true) { if (typeof onDestroy === "function") { onDestroy(node); } else { destroyWithClass(node, onDestroy); } // This causes the element to be ignored from this function from now on: onDestroyMap.set(node, true); } // Ignore the deleting element } else { (node as Element | Text).remove(); } node = prevNode; } } // Move `node` and all its preceding siblings (up to and excluding `preNode`) to just after // `afterNode` (or to the start of `parentEl` when `afterNode` is undefined), preserving their // order. Elements currently playing their destroy animation are left where they are, like // `removeNodes` does. Uses `moveBefore` when the browser supports it, which keeps state such // as focus, text selection, CSS animations and iframe documents intact. Otherwise // `insertBefore` is used, which preserves the elements themselves (and things like `` // values), but resets such state. function moveNodes( node: Node | undefined, preNode: Node | undefined, parentEl: Element, afterNode: Node | undefined, ) { // `moveBefore` throws when the parent (and thus the nodes) are not connected to the // document; `isConnected` is undefined in our fake test DOM, which is always "connected". const method = (parentEl as any).moveBefore && parentEl.isConnected !== false ? "moveBefore" : "insertBefore"; // Walk backwards, moving each node to right before the previously moved one. The previous // sibling is captured before the move, as moving the node would upset our walk. let ref: Node | null = afterNode ? afterNode.nextSibling : parentEl.firstChild; while (node && node !== preNode) { const prevNode: Node | null = node.previousSibling; if (onDestroyMap.get(node) !== true) { (parentEl as any)[method](node, ref); ref = node; } node = prevNode || undefined; } } // Get a reference to the last node within `sibling` or any of its preceding siblings. // If a `Node` is given, that node is returned. function findLastNodeInPrevSiblings( sibling: Node | Scope | undefined, ): Node | undefined { if (!sibling || sibling instanceof Node) return sibling; return sibling.getLastNode() || sibling.getPrecedingNode(); } class ResultScope extends ChainedScope { public result: ValueRef = optProxy({ value: undefined }); constructor( public renderer: () => T, ) { super(currentScope.el, currentScope.svg); this.redraw(); } redraw() { const savedScope = currentScope; currentScope = this; try { this.result.value = this.renderer(); } catch (e) { // Throw the error async, so the rest of the rendering can continue handleError(e, true); } currentScope = savedScope; } } /** * A `Scope` subclass optimized for reactively setting just a single element property * based on a proxied reference. */ class SetArgScope extends ChainedScope { public svg = false; constructor( el: Element, private key: string, private target: { value: any }, ) { super(el, el.namespaceURI === 'http://www.w3.org/2000/svg'); this.redraw(); } redraw() { const savedScope = currentScope; currentScope = this; applyArg(this.el, this.key, this.target.value); currentScope = savedScope; } } /** @internal */ class OnEachScope extends Scope { // biome-ignore lint/correctness/noInvalidUseBeforeDeclaration: circular, as currentScope is initialized with a Scope parentElement: Element = currentScope.el; prevSibling: Node | Scope | undefined; /** The data structure we are iterating */ target: TargetType; /** All item scopes, by array index or object key. This is used for removing an item scope when its value * disappears, and calling all subscope cleaners. */ byIndex: Map = new Map(); /** The reverse-ordered list of item scopes, not including those for which makeSortKey returned undefined. */ sortedSet: ReverseSortedSet = new ReverseSortedSet("sortKey"); /** Indexes that have been created/removed and need to be handled in the next `queueRun`. */ changedIndexes: Map = new Map(); // index => old value constructor( proxy: TargetType, /** A function that renders an item */ public renderer: (value: any, key: any) => void, /** A function returning a number/string/array that defines the position of an item */ public makeSortKey?: (value: any, key: any) => SortKeyType, ) { super(); const target: TargetType = (this.target = (proxy as any)[TARGET_SYMBOL] || proxy); subscribe(target, ANY_SYMBOL, this); this.prevSibling = currentScope.getChildPrevSibling(); currentScope.lastChild = this; currentScope.cleaners.push(this); // Register before creating item scopes, so they can attach under this scope. dev?.create(this, currentScope, new Error()); // Do _addChild() calls for initial items if (target instanceof Array) { for (let i = 0; i < target.length; i++) { new OnEachItemScope(this, i, false); } } else { for (const key of (target instanceof Map ? target.keys() : target instanceof Set ? target.values() : Object.keys(target))) { new OnEachItemScope(this, key, false); } } } getPrecedingNode(): Node | undefined { return findLastNodeInPrevSiblings(this.prevSibling); } onChange(target: TargetType, index: any, newData: any, oldData: any) { // target === this.target if (!(target instanceof Array) || typeof index === "number") { if (this.changedIndexes.has(index)) { if (this.changedIndexes.get(index) === newData) { // Data changed back to original value, so ignore it this.changedIndexes.delete(index); } // Else, data changed a second time } else { // Initial data change this.changedIndexes.set(index, oldData); queue(this); } } } queueRun() { const indexes = this.changedIndexes; this.changedIndexes = new Map(); for (const index of indexes.keys()) { const oldScope = this.byIndex.get(index); if (oldScope) { oldScope.remove(); // The old item scope is discarded here (recreated below if it survives), so drop it // from the dev tree; a fresh OnEachItemScope re-attaches itself. dev?.delete(oldScope); } if (this.target instanceof Set || this.target instanceof Map ? this.target.has(index) : index in this.target) { // Item still exists new OnEachItemScope(this, index, true); } else { // Item has disappeared this.byIndex.delete(index); } } topRedrawScope = undefined; } delete() { // Propagate to all our subscopes for (const scope of this.byIndex.values()) { scope.delete(); } sortedQueue?.remove(this); // This is very fast and O(1) when not queued // Help garbage collection: this.byIndex.clear(); setTimeout(() => { // Unsure if this is a good idea. It takes time, but presumably makes things a lot easier for GC... this.sortedSet.clear(); }, 1); } getLastNode(): Node | undefined { for (const scope of this.sortedSet) { // Iterates starting at last child scope. const node = scope.getActualLastNode(); if (node) return node; } } } /** @internal */ class OnEachItemScope extends ContentScope { sortKey: string | null | undefined; // When null-ish, this scope is currently not showing in the list public el: Element; public svg: boolean; constructor( public parent: OnEachScope, public itemIndex: any, topRedraw: boolean, ) { super(); this.el = parent.parentElement; // Inherit SVG namespace state from current scope this.svg = currentScope.svg; this.parent.byIndex.set(this.itemIndex, this); // Okay, this is hacky. In case our first (actual) child is a ChainedScope, we won't be able // to provide it with a reliable prevSibling. Therefore, we'll pretend to be that sibling, // doing what's need for this case in `getLastNode`. // For performance, we prefer not having to create additional 'fake sibling' objects for each item. this.lastChild = this; // Don't register to be cleaned by parent scope, as the OnEachScope will manage this for us (for efficiency) // An onEach item is its own scope; its parent in the tree is the OnEachScope. dev?.create(this, this.parent, new Error()); if (topRedraw) topRedrawScope = this; this.redraw(); } getPrecedingNode(): Node | undefined { // As apparently we're interested in the node insert position, we'll need to become part // of the sortedSet now (if we weren't already). // This will do nothing and barely take any time of `this` is already part of the set: this.parent.sortedSet.add(this); const preScope = this.parent.sortedSet.prev(this); // As preScope should have inserted itself as its first child, this should // recursively call getPrecedingNode() on preScope in case it doesn't // have any actual nodes as children yet. if (preScope) return findLastNodeInPrevSiblings(preScope.lastChild); return this.parent.getPrecedingNode(); } getLastNode(): Node | undefined { // Hack! As explain in the constructor, this getLastNode method actually // does not return the last node, but the preceding one. return this.getPrecedingNode(); } getActualLastNode(): Node | undefined { let child = this.lastChild; while (child && child !== this) { if (child instanceof Node) return child; const node = child.getLastNode(); if (node) return node; child = child.getPrecedingNode(); } } queueRun() { /* c8 ignore next */ if (currentScope !== ROOT_SCOPE) internalError(4); if (!this.fetchHasChanges()) return; this.fullRedraw(); } /** Removes the item's rendered nodes, cleans its subscopes and subscriptions, and * renders it afresh (which may change or clear its position among its siblings). */ fullRedraw() { // We're not calling `remove` here, as we don't want to remove ourselves from // the sorted set. `redraw` will take care of that, if needed. // Also, we can't use `getLastNode` here, as we've hacked it to return the // preceding node instead. if (this.sortKey != null) { const lastNode = this.getActualLastNode(); if (lastNode) removeNodes(lastNode, this.getPrecedingNode()); } // Our `el` (the list's parent element) survives; restore its value-restoring cleaners. const savedSurvivingEl = survivingEl; survivingEl = this.el; this.delete(); survivingEl = savedSurvivingEl; this.lastChild = this; // apply the hack (see constructor) again topRedrawScope = this; this.redraw(); topRedrawScope = undefined; } /** Returns the (proxied) `[value, key]` pair for this item, as passed to the user's * `render` and `makeSortKey` functions. */ getValueAndIndex(): [any, any] { const target = this.parent.target; let itemIndex = this.itemIndex; let value: any; if (target instanceof Set) { value = itemIndex = optProxy(itemIndex); } else if (target instanceof Map) { value = optProxy(target.get(itemIndex)); // For Maps, the key may be an object. If so, we'll proxy it as well. itemIndex = optProxy(itemIndex); } else { value = optProxy((target as any)[itemIndex]); } return [value, itemIndex]; } redraw() { // Note that we're NOT subscribing on target[itemIndex], as the OnEachScope uses // a wildcard subscription to delete/recreate any scopes when that changes. // We ARE creating a proxy around the value though (in case its an object/array), // so we'll have our own scope subscribe to changes on that. const [value, itemIndex] = this.getValueAndIndex(); // Since makeSortKey may get() the Store, we'll need to set currentScope first. const savedScope = currentScope; currentScope = this; dev?.render(this); let sortKey: string | null | undefined; try { if (this.parent.makeSortKey) { // The sort key is computed in a (sub)scope of its own, so that a change // affecting only the key (and not the rendered content) can reposition // the item without redrawing it. sortKey = new SortKeyScope(this).compute(value, itemIndex); } else { sortKey = normalizeSortKey(itemIndex); } if (this.sortKey !== sortKey) { // If the sortKey is changed, make sure `this` is removed from the // set before setting the new sortKey to it. this.parent.sortedSet.remove(this); // Very fast if `this` is not in the set this.sortKey = sortKey; } // We're not adding `this` to the `sortedSet` (yet), as that may not be needed, // in case no nodes are created. We'll do it just-in-time in `getPrecedingNode`. if (sortKey != null) this.parent.renderer(value, itemIndex); } catch (e) { handleError(e, sortKey != null); } currentScope = savedScope; } getInsertAfterNode() { if (this.sortKey == null) internalError(1); // Due to the `this` being the first child for `this` hack, this will look // for the preceding node as well, if we don't have nodes ourselves. return findLastNodeInPrevSiblings(this.lastChild); } /** Repositions this item to match a changed `newKey`, moving its DOM nodes to their new * position without redrawing them. Requires both the old and the new sort key to be * non-null (meaning the item stays visible, and its content is unaffected). */ move(newKey: string) { // Determine the current node range before changing our position. An item that has // rendered any nodes is always part of the sortedSet, as inserting a node requires // looking up its position through getPrecedingNode(). const lastNode = this.getActualLastNode(); const precedingNode = lastNode && this.getPrecedingNode(); this.parent.sortedSet.remove(this); this.sortKey = newKey; // Without nodes there is nothing to move; we'll rejoin the sortedSet just-in-time // (see getPrecedingNode), like after a redraw. if (!lastNode) return; // This re-adds us to the sortedSet (now at the new position), and returns the node // after which our nodes should go from now on. const afterNode = this.getPrecedingNode(); if (afterNode !== precedingNode) moveNodes(lastNode, precedingNode, this.el, afterNode); } remove() { // We can't use getLastNode here, as we've hacked it to return the preceding // node instead. if (this.sortKey != null) { const lastNode = this.getActualLastNode(); if (lastNode) removeNodes(lastNode, this.getPrecedingNode()); this.parent.sortedSet.remove(this); this.sortKey = undefined; } // Our `el` (the list's parent element) survives the removal of just this item. const savedSurvivingEl = survivingEl; survivingEl = this.el; this.delete(); survivingEl = savedSurvivingEl; } } /** @internal * Computes an item's sort key within a scope of its own, so that a change to observable data * read by `makeSortKey` (but not by the item's render function) can reposition the item * without redrawing it. Created only when `onEach` was given a `makeSortKey` function. A full * (re)render of the item deletes this scope (through the item's cleaners) and creates a * fresh one. */ class SortKeyScope extends ContentScope { el: Element; svg: boolean; constructor(public item: OnEachItemScope) { super(); this.el = item.el; this.svg = item.svg; // Have the item scope clean us up when it redraws or is removed. item.cleaners.push(this); dev?.create(this, item, new Error()); } /** Runs `makeSortKey` with `this` as the subscribing scope, returning the normalized * key. Exceptions propagate to the caller. */ compute(value: any, itemIndex: any): string | null | undefined { const savedScope = currentScope; currentScope = this; dev?.render(this); try { return normalizeSortKey(this.item.parent.makeSortKey!(value, itemIndex)); } finally { currentScope = savedScope; } } queueRun() { if (!this.fetchHasChanges()) return; const item = this.item; const oldKey = item.sortKey; // Drop the subscriptions of the previous computation; compute() will re-establish them. this.delete(); let newKey: string | null | undefined; try { newKey = this.compute(...item.getValueAndIndex()); } catch (e) { handleError(e, false); newKey = undefined; } if (newKey === oldKey || (newKey == null && oldKey == null)) return; // Position unaffected. if (newKey == null || oldKey == null) { // The item is toggling between shown and hidden, which changes what is rendered: // fall back to a full redraw. (This also deletes `this`, creating a fresh // SortKeyScope that recomputes the key.) item.fullRedraw(); } else { // Only the position changed: move the item's DOM nodes without redrawing them. item.move(newKey); } } /* c8 ignore next 3 -- satisfies the abstract contract; we hold no nodes, so it's never called */ getPrecedingNode(): undefined { return undefined; } getInsertAfterNode(): Node | undefined { throw new Error("makeSortKey must not create DOM nodes"); } } function addNode(el: Element, node: Node) { if (el !== currentScope.el) { el.appendChild(node); dev?.node(currentScope, node, false); return; } const parentEl = currentScope.el; const prevNode = currentScope.getInsertAfterNode(); parentEl.insertBefore( node, prevNode ? prevNode.nextSibling : parentEl.firstChild, ); currentScope.lastChild = node; dev?.node(currentScope, node, true); } /** * This global is set during the execution of a `Scope.render`. It is used by * functions like `$` and `clean`. */ const ROOT_SCOPE = new RootScope(); let currentScope: ContentScope = ROOT_SCOPE; // === Developer tools instrumentation ======================================= // The core emits events through `dev(type, ...args)` only when the dev tools are connected. // The tools — the scope tree, node→scope / element→scope maps, stack traces, the UI, and the // `?abdev=1` / Ctrl-Alt-A bootstrap — all live in `./devtools`, which *wraps* this module: // in a dev build the `aberdeen` entry point is that wrapper, and it installs the sink via // `A._setDev` synchronously at load (before the app's first `mount()`, so no buffering). // // There is deliberately no build-time flag: the hooks are always present, costing only a // nullish check (`dev?.x(…)` doesn't even evaluate its arguments — no `new Error()`) when no // tools are attached. Keeping them unconditional avoids the footgun of a stripped core being // paired with the wrapper, or vice-versa. /** @internal The instrumentation hooks the dev tools install via {@link _setDev}. Scopes are * passed opaquely (`any`); the tools read `.constructor.name`, `.el`, etc. off them. */ export interface DevHooks { /** A scope was created under `parent` (its onEach scope, for items); `err` holds the creation * stack, or is `undefined` for scopes replayed on connect (they predate the tools). */ create(scope: any, parent: any, err: Error | undefined): void; /** A scope is about to (re)render, replacing the DOM nodes and child scopes it last produced. */ render(scope: any): void; /** A proxy change (`target[index]`, `oldData`→`newData`) scheduled `scope` to re-render; `err` holds the triggering stack. */ schedule(scope: any, index: any, oldData: any, newData: any, err: Error): void; /** `scope` inserted DOM `node`; `top` marks a direct sibling at the scope's own level (vs. a descendant). */ node(scope: any, node: Node, top: boolean): void; /** `scope` subscribed to `target[index]`. */ read(scope: any, target: any, index: any): void; /** `scope` was permanently removed (an onEach item that no longer exists). */ delete(scope: any): void; } /** @internal Event sink, installed by the dev-tools wrapper via {@link _setDev}. */ let dev: DevHooks | undefined; /** @internal Install (or, with `undefined`, remove) the dev-tools hooks. Used by the * `./devtools` wrapper. A named `@internal` export (rather than an `A` member) so it is * stripped from the public type declarations. * * On connect the *already existing* scope tree is replayed as `create` events (with no * stack — those scopes predate the tools), so the tool shows the current tree immediately, * not only what changes afterwards. Their child scopes live in `cleaners` (and onEach items * in `byIndex`); nodes/reads/stacks for them fill in naturally as they re-render. */ export function _setDev(hooks: DevHooks | undefined) { dev = hooks; if (!hooks) return; const childScopes = (scope: any): Scope[] => scope instanceof OnEachScope ? [...scope.byIndex.values()] : (scope.cleaners as any[]).filter((c): c is Scope => c instanceof Scope); const replay = (scope: Scope, parent: Scope) => { hooks.create(scope, parent, undefined); for (const child of childScopes(scope)) replay(child, scope); }; for (const child of childScopes(ROOT_SCOPE)) replay(child, ROOT_SCOPE); } // === End developer tools instrumentation =================================== /** * Execute a function in a never-cleaned root scope. Even {@link unmountAll} will not * clean up observers/nodes created by the function. * @param func The function to execute. * @returns The return value of the function. * @internal */ export function leakScope(func: () => T): T { const savedScope = currentScope; currentScope = new RootScope(); try { return func(); } finally { currentScope = savedScope; } } /** * A special Node observer index to subscribe to any value in the map changing. */ const ANY_SYMBOL = Symbol("any"); /** * When our proxy objects need to lookup `obj[TARGET_SYMBOL]` it returns its * target, to be used in our wrapped methods. */ const TARGET_SYMBOL = Symbol("target"); /** * Symbol used internally to track Map and Set size without clashing with actual Map keys named "size". * * @internal */ export const MAP_SIZE_SYMBOL = Symbol("mapSize"); const subscribers = new WeakMap< TargetType, Map< any, Set void)> > >(); let peeking = 0; // When > 0, we're not subscribing to any changes function subscribe( target: any, index: symbol | string | number, observer: | Scope | (( index: any, newData: any, oldData: any, ) => void) = currentScope, ) { if (observer === ROOT_SCOPE || peeking) return; if (observer === currentScope) dev?.read(currentScope, target, index); let byTarget = subscribers.get(target); if (!byTarget) subscribers.set(target, (byTarget = new Map())); // No need to subscribe to specific keys if we're already subscribed to ANY if (index !== ANY_SYMBOL && byTarget.get(ANY_SYMBOL)?.has(observer)) return; let byIndex = byTarget.get(index); if (!byIndex) byTarget.set(index, (byIndex = new Set())); if (byIndex.has(observer)) return; byIndex.add(observer); if (observer === currentScope) { currentScope.cleaners.push(byIndex); } else { currentScope.cleaners.push(() => { byIndex.delete(observer); }); } } /** * Records in TypeScript pretend that they can have number keys, but in reality they are converted to string. * This type changes (number | something) types to (string | something) types, maintaining typing precision as much as possible. * @internal */ type KeyToString = K extends number ? string : K extends string | symbol ? K : K extends number | infer U ? string | U : K; export function onEach( target: Map, render: (value: T, key: K) => void, makeKey?: (value: T, key: K) => SortKeyType, ): void; export function onEach( target: Set, render: (value: T) => void, makeKey?: (value: T) => SortKeyType, ): void; export function onEach( target: ReadonlyArray, render: (value: T, index: number) => void, makeKey?: (value: T, index: number) => SortKeyType, ): void; export function onEach( target: Record, render: (value: T, index: KeyToString) => void, makeKey?: (value: T, index: KeyToString) => SortKeyType, ): void; /** * Reactively iterates over the items of an observable array, object, Map, or Set, optionally rendering content for each item. * * Automatically updates when items are added, removed, or modified. * * @param target The observable array, object, Map, or Set to iterate over. Values that are `undefined` are skipped. * @param render A function called for each item. It receives the item's (observable) value and its index/key. For Sets, only the value is provided. Any DOM elements created within this function will be associated with the item, placed at the right spot in the DOM, and cleaned up when redrawing/removing the item. * @param makeKey An optional function to generate a sort key for each item. This controls the order in which items are rendered in the DOM. If omitted, arrays use index order, Sets use the item value itself, and objects/Maps use their natural key order. The returned key can be a number, string, or an array of numbers/strings for composite sorting. Use {@link invertString} on string keys for descending order. Returning `null` or `undefined` from `makeKey` will prevent the item from being rendered. * * `makeKey` runs in a reactive scope of its own: when observable data it reads changes the resulting key, the item's DOM nodes are *moved* to their new position without being redrawn (so state like `` values survives, and — in browsers supporting the `moveBefore` API — focus, text selection and CSS animations do too). Changes that switch the key between `null`-ish and an actual value show/hide the item, and therefore do cause a (re)render. `makeKey` must not create DOM nodes. * * @example Iterating an array * ```typescript * const $items = A.proxy(['apple', 'banana', 'cherry']); * * // Basic iteration * A.onEach($items, (item, index) => A(`li#${item} (#${index})`)); * * // Add a new item - the list updates automatically * setTimeout(() => $items.push('durian'), 2000); * // Same for updates and deletes * setTimeout(() => $items[1] = 'berry', 4000); * setTimeout(() => delete $items[2], 6000); * ``` * * @example Iterating an array with custom ordering * ```typescript * const $users = A.proxy([ * { id: 3, group: 1, name: 'Charlie' }, * { id: 1, group: 1, name: 'Alice' }, * { id: 2, group: 2, name: 'Bob' }, * ]); * * // Sort by name alphabetically * A.onEach($users, ($user) => { * A(`p#${$user.name} (id=${$user.id})`); * }, ($user) => [$user.group, $user.name]); // Sort by group, and within each group sort by name * ``` * * @example Iterating an object * ```javascript * const $config = A.proxy({ theme: 'dark', fontSize: 14, showTips: true }); * * // Display configuration options * A('dl', () => { * A.onEach($config, (value, key) => { * if (key === 'showTips') return; // Don't render this one * A('dt#'+key); * A('dd#'+value); * }); * }); * * // Change a value - the display updates automatically * setTimeout(() => $config.fontSize = 16, 2000); * ``` * * @example Iterating a Set * ```javascript * const $tags = A.proxy(new Set(['ui', 'fast', 'tiny'])); * * A('ul', () => { * A.onEach($tags, (tag) => { // Defaults to alphabetically ordering by tag * A(`li#${tag}`); * }); * }); * * setTimeout(() => $tags.add('reactive'), 2000); * ``` * @see {@link invertString} To easily create keys for reverse sorting. */ export function onEach( target: TargetType, render: (value: any, index: any) => void, makeKey?: (value: any, key: any) => SortKeyType, ): void { if (!target || typeof target !== "object") throw new Error("A.onEach requires an object"); target = (target as any)[TARGET_SYMBOL] || target; new OnEachScope(target, render, makeKey); } function isObjEmpty(obj: object): boolean { for (const k of Object.keys(obj)) return false; return true; } /** @private */ export const EMPTY = Symbol("empty"); /** * Reactively checks if an observable array, object, Map, or Set is empty. * * This function not only returns the current emptiness state but also establishes * a reactive dependency. If the emptiness state of the `proxied` object or array * changes later (e.g., an item is added to an empty array, or the last property * is deleted from an object), the scope that called `isEmpty` will be automatically * scheduled for re-evaluation. * * @param proxied The observable array, object, Map, or Set to check. * @returns `true` if the array has length 0, the Map/Set has size 0, or the object has no own enumerable properties, `false` otherwise. * * @example * ```typescript * const $items = A.proxy([]); * * // Reactively display a message if the items array is empty * A('div', () => { * if (A.isEmpty($items)) { * A('p i#No items yet!'); * } else { * A.onEach($items, item => A('p#'+item)); * } * }); * * // Adding an item will automatically remove the "No items yet!" message * setInterval(() => { * if (!$items.length || Math.random()>0.5) $items.push('Item'); * else $items.length = 0; * }, 1000) * ``` */ export function isEmpty(proxied: TargetType): boolean { const target = (proxied as any)[TARGET_SYMBOL] || proxied; const scope = currentScope; if (target instanceof Array) { subscribe(target, "length", (index: any, newData: any, oldData: any) => { if (!newData !== !oldData) scope.onChange(target, EMPTY, !newData, !oldData); }); return !target.length; } if (target instanceof Map || target instanceof Set) { subscribe(target, MAP_SIZE_SYMBOL, (index: any, newData: any, oldData: any) => { if (!newData !== !oldData) scope.onChange(target, EMPTY, !newData, !oldData); }); return !target.size; } let oldEmpty = isObjEmpty(target); subscribe(target, ANY_SYMBOL, (index: any, newData: any, oldData: any) => { if ((newData === EMPTY) !== (oldData === EMPTY)) { const newEmpty = isObjEmpty(target); if (newEmpty !== oldEmpty) { scope.onChange(target, EMPTY, newEmpty, oldEmpty); oldEmpty = newEmpty; } } }); return oldEmpty; } /** @private */ export interface ValueRef { value: T; } /** * Reactively counts the number of properties in an object. * * @param proxied The observable object to count. In case an `array`, `Map`, or `Set` is passed in, a {@link ref} to its `.length` or `.size` will be returned. * @returns an observable object for which the `value` property reflects the number of properties in `proxied` with a value other than `undefined`, or the collection size for arrays, Maps, and Sets. * * @example * ```typescript * const $items = A.proxy({x: 3, y: 7} as any); * const $count = A.count($items); * * // Create a DOM text node for the count: * A('div text=', $count); * //
2
* // Or we can use it in an {@link derive} function: * A(() => console.log("The count is now", $count.value)); * // The count is now 2 * * // Adding/removing items will update the count * $items.z = 12; * // Asynchronously, after 0ms: * //
3
* // The count is now 3 * ``` */ export function count(proxied: TargetType): ValueRef { if (proxied instanceof Array) return ref(proxied, "length"); if (proxied instanceof Map || proxied instanceof Set) return ref(proxied, "size"); const target = (proxied as any)[TARGET_SYMBOL] || proxied; let cnt = 0; for (const k of Object.keys(target)) if (target[k] !== undefined) cnt++; const result = proxy(cnt); subscribe( target, ANY_SYMBOL, (index: any, newData: any, oldData: any) => { if (oldData === newData) { } else if (oldData === EMPTY) result.value = ++cnt; else if (newData === EMPTY) result.value = --cnt; }, ); return result; } /** @internal */ export function defaultEmitHandler( target: TargetType, index: string | symbol | number, newData: any, oldData: any, ) { // We're triggering for values changing from undefined to undefined, as this *may* // indicate a change from or to `[empty]` (such as `[,1][0]`). if (newData === oldData && newData !== undefined) return; const byTarget = subscribers.get(target); if (byTarget === undefined) return; for (const what of [index, ANY_SYMBOL]) { const byIndex = byTarget.get(what); if (byIndex) { for (const observer of byIndex) { if (typeof observer === "function") observer(index, newData, oldData); else observer.onChange(target, index, newData, oldData); } } } } let emit = defaultEmitHandler; const objectHandler: ProxyHandler = { get(target: any, prop: any) { if (prop === TARGET_SYMBOL) return target; subscribe(target, prop); return optProxy(target[prop]); }, set(target: any, prop: any, newData: any) { // Make sure newData is unproxied if (typeof newData === "object" && newData) newData = (newData as any)[TARGET_SYMBOL] || newData; const oldData = target.hasOwnProperty(prop) ? target[prop] : EMPTY; if (newData !== oldData) { target[prop] = newData; emit(target, prop, newData, oldData); } return true; }, deleteProperty(target: any, prop: any) { const old = target.hasOwnProperty(prop) ? target[prop] : EMPTY; delete target[prop]; emit(target, prop, EMPTY, old); return true; }, has(target: any, prop: any) { subscribe(target, prop); return target.hasOwnProperty(prop); }, ownKeys(target: any) { subscribe(target, ANY_SYMBOL); return Reflect.ownKeys(target); }, }; function arraySet(target: any, prop: any, newData: any) { // Make sure newData is unproxied if (typeof newData === "object" && newData) { newData = (newData as any)[TARGET_SYMBOL] || newData; } let oldData = target[prop]; if (oldData === undefined && !target.hasOwnProperty(prop)) oldData = EMPTY; if (newData !== oldData) { const oldLength = target.length; if (prop === "length") { target.length = newData; // We only need to emit for shrinking, as growing just adds undefineds for (let i = newData; i < oldLength; i++) { emit(target, i, EMPTY, target[i]); } } else { if (typeof prop === 'string') { // Convert to int when possible const n = 0|prop as any; if (String(n) === prop && n >= 0) prop = n; } target[prop] = newData; emit(target, prop, newData, oldData); } if (target.length !== oldLength) { emit(target, "length", target.length, oldLength); } } return true; } const arrayHandler: ProxyHandler = { get(target: any, prop: any) { if (prop === TARGET_SYMBOL) return target; if (typeof prop === 'string') { // Convert to int when possible const n = 0|prop as any; if (String(n) === prop && n >= 0) prop = n; } subscribe(target, prop); return optProxy(target[prop]); }, set: arraySet, deleteProperty(target: any, prop: any) { if (typeof prop === 'string') { // Convert to int when possible const n = 0|prop as any; if (String(n) === prop && n >= 0) prop = n; } let oldData = target[prop]; if (oldData === undefined && !target.hasOwnProperty(prop)) oldData = EMPTY; delete target[prop]; emit(target, prop, EMPTY, oldData); return true; }, }; /** * Helper functions that wrap iterators to proxy values */ function wrapIteratorSingle(iterator: IterableIterator): IterableIterator { return { [Symbol.iterator]() { return this; }, next() { const result = iterator.next(); if (result.done) return result; return { done: false, value: optProxy(result.value) }; } }; } function wrapIteratorPair(iterator: IterableIterator<[any, any]>): IterableIterator<[any, any]> { return { [Symbol.iterator]() { return this; }, next() { const result = iterator.next(); if (result.done) return result; return { done: false, value: [optProxy(result.value[0]), optProxy(result.value[1])] }; } }; } function unproxyCollectionValue(value: T): T { return typeof value === "object" && value ? ((value as any)[TARGET_SYMBOL] || value) : value; } const mapMethodHandlers = { get(this: any, key: any): any { const target: Map = this[TARGET_SYMBOL]; key = unproxyCollectionValue(key); subscribe(target, key); return optProxy(target.get(key)); }, set(this: any, key: any, newData: any): any { const target: Map = this[TARGET_SYMBOL]; key = unproxyCollectionValue(key); newData = unproxyCollectionValue(newData); let oldData = target.get(key); if (oldData === undefined && !target.has(key)) oldData = EMPTY; if (newData !== oldData) { const oldSize = target.size; target.set(key, newData); emit(target, key, newData, oldData); emit(target, MAP_SIZE_SYMBOL, target.size, oldSize); } return this; }, delete(this: any, key: any): boolean { const target: Map = this[TARGET_SYMBOL]; key = unproxyCollectionValue(key); let oldData = target.get(key); if (oldData === undefined && !target.has(key)) oldData = EMPTY; const result: boolean = target.delete(key); if (result) { emit(target, key, EMPTY, oldData); emit(target, MAP_SIZE_SYMBOL, target.size, target.size + 1); } return result; }, clear(this: any): void { const target: Map = this[TARGET_SYMBOL]; const oldSize = target.size; for (const key of target.keys()) { emit(target, key, undefined, target.get(key)); } target.clear(); emit(target, MAP_SIZE_SYMBOL, 0, oldSize); }, has(this: any, key: any): boolean { const target: Map = this[TARGET_SYMBOL]; key = unproxyCollectionValue(key); subscribe(target, key); return target.has(key); }, keys(this: any): IterableIterator { const target: Map = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorSingle(target.keys()); }, values(this: any): IterableIterator { const target: Map = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorSingle(target.values()); }, entries(this: any): IterableIterator<[any, any]> { const target: Map = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorPair(target.entries()); }, [Symbol.iterator](this: any): IterableIterator<[any, any]> { const target: Map = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorPair(target[Symbol.iterator]()); } }; const setMethodHandlers = { add(this: any, value: any): any { const target: Set = this[TARGET_SYMBOL]; value = unproxyCollectionValue(value); if (!target.has(value)) { const oldSize = target.size; target.add(value); emit(target, value, value, EMPTY); emit(target, MAP_SIZE_SYMBOL, target.size, oldSize); } return this; }, delete(this: any, value: any): boolean { const target: Set = this[TARGET_SYMBOL]; value = unproxyCollectionValue(value); if (!target.has(value)) return false; const oldSize = target.size; target.delete(value); emit(target, value, EMPTY, value); emit(target, MAP_SIZE_SYMBOL, target.size, oldSize); return true; }, clear(this: any): void { const target: Set = this[TARGET_SYMBOL]; const oldSize = target.size; if (!oldSize) return; for (const value of target.values()) emit(target, value, EMPTY, value); target.clear(); emit(target, MAP_SIZE_SYMBOL, 0, oldSize); }, has(this: any, value: any): boolean { const target: Set = this[TARGET_SYMBOL]; value = unproxyCollectionValue(value); subscribe(target, value); return target.has(value); }, keys(this: any): IterableIterator { const target: Set = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorSingle(target.keys()); }, values(this: any): IterableIterator { const target: Set = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorSingle(target.values()); }, entries(this: any): IterableIterator<[any, any]> { const target: Set = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorPair(target.entries()); }, [Symbol.iterator](this: any): IterableIterator { const target: Set = this[TARGET_SYMBOL]; subscribe(target, ANY_SYMBOL); return wrapIteratorSingle(target[Symbol.iterator]()); } }; const mapHandler: ProxyHandler> = { get(target: Map, prop: any) { if (prop === TARGET_SYMBOL) return target; // Handle Map methods using lookup object if (mapMethodHandlers.hasOwnProperty(prop)) { return (mapMethodHandlers as any)[prop]; } // Handle size property if (prop === "size") { subscribe(target, MAP_SIZE_SYMBOL); return target.size; } // Handle other properties normally return (target as any)[prop]; }, }; const setHandler: ProxyHandler> = { get(target: Set, prop: any) { if (prop === TARGET_SYMBOL) return target; if (setMethodHandlers.hasOwnProperty(prop)) { return (setMethodHandlers as any)[prop]; } if (prop === "size") { subscribe(target, MAP_SIZE_SYMBOL); return target.size; } return (target as any)[prop]; }, }; const proxyMap = new WeakMap(); function optProxy(value: any): any { // If value is a primitive type or already proxied, just return it if ( typeof value !== "object" || value === null || value[TARGET_SYMBOL] !== undefined || value[OPAQUE] ) { return value; } let proxied = proxyMap.get(value); if (proxied) return proxied; // Only one proxy per target! let handler; if (value instanceof Array) { handler = arrayHandler; } else if (value instanceof Map) { handler = mapHandler; } else if (value instanceof Set) { handler = setHandler; } else { handler = objectHandler; } proxied = new Proxy(value, handler); proxyMap.set(value, proxied as TargetType); return proxied; } /** * When `proxy` is called with a Promise, the returned object has this shape. */ export interface PromiseProxy { /** * True if the promise is still pending, false if it has resolved or rejected. */ busy: boolean; /** * If the promise has resolved, this contains the resolved value. */ value?: T; /** * If the promise has rejected, this contains the rejection error. */ error?: any; } export function proxy(target: Promise): PromiseProxy; export function proxy(target: Array): Array; export function proxy(target: T): T; export function proxy(target: T): ValueRef; /** * Creates a reactive proxy around the given data. * * Reading properties from the returned proxy within a reactive scope (like one created by * {@link A | A} or {@link derive}) establishes a subscription. Modifying properties *through* * the proxy will notify subscribed scopes, causing them to re-execute. * * - Plain objects, arrays, Maps, and Sets are wrapped in a standard JavaScript `Proxy` that intercepts * property access and mutations, but otherwise works like the underlying data. * - Primitives (string, number, boolean, null, undefined) are wrapped in an object * `{ value: T }` which is then proxied. Access the primitive via the `.value` property. * - Promises are represented by proxied objects `{ busy: boolean, value?: T, error?: any }`. * Initially, `busy` is `true`. When the promise resolves, `value` is set and `busy` * is set to `false`. If the promise is rejected, `error` is set and `busy` is also * set to `false`. * * Use {@link unproxy} to get the original underlying data back. * By convention in the examples below, local variables that hold proxied values are prefixed with `$`. * * @param target - The object, array, Map, Set, or primitive value to make reactive. * @returns A reactive proxy wrapping the target data. * @template T - The type of the data being proxied. * * @example Object * ```javascript * const $state = A.proxy({ count: 0, message: 'Hello' }); * A(() => console.log($state.message)); // Subscribes to message * setTimeout(() => $state.message = 'World', 1000); // Triggers the observing function * setTimeout(() => $state.count++, 2000); // Triggers nothing * ``` * * @example Array * ```javascript * const $items = A.proxy(['a', 'b']); * A(() => console.log($items.length)); // Subscribes to length * setTimeout(() => $items.push('c'), 2000); // Triggers the observing function * ``` * * @example Primitive * ```javascript * const $name = A.proxy('Aberdeen'); * A(() => console.log($name.value)); // Subscribes to value * setTimeout(() => $name.value = 'UI', 2000); // Triggers the observing function * ``` * * @example Set * ```javascript * const $tags = A.proxy(new Set(['ui', 'tiny'])); * A(() => console.log($tags.has('ui'), $tags.size)); * setTimeout(() => $tags.add('fast'), 1000); * ``` * * @example Class instance * ```typescript * class Widget { * constructor(public name: string, public width: number, public height: number) {} * grow() { this.width *= 2; } * toString() { return `${this.name}Widget (${this.width}x${this.height})`; } * } * let $graph: Widget = A.proxy(new Widget('Graph', 200, 100)); * A(() => console.log(''+$graph)); * setTimeout(() => $graph.grow(), 2000); * setTimeout(() => $graph.grow(), 4000); * ``` */ export function proxy(target: TargetType): TargetType { if (target instanceof Promise) { const result: PromiseProxy = optProxy({ busy: true, }); target .then((value) => { result.value = value; result.busy = false; }) .catch((err) => { result.error = err; result.busy = false; }); return result; } return optProxy( typeof target === "object" && target !== null ? target : { value: target }, ); } /** * Returns the original, underlying data target from a reactive proxy created by {@link proxy}. * If the input `target` is not a proxy, it is returned directly. * * This is useful when you want to avoid triggering subscriptions during read operations or * re-executes during write operations. Using {@link peek} is an alternative way to achieve this. * * @param target - A proxied object, array, or any other value. * @returns The underlying (unproxied) data, or the input value if it wasn't a proxy. * @template T - The type of the target. * * @example * ```typescript * const $user = A.proxy({ name: 'Frank' }); * const rawUser = A.unproxy($user); * * // Log reactively * A(() => console.log('proxied', $user.name)); * // The following will only ever log once, as we're not subscribing to any observable * A(() => console.log('unproxied', rawUser.name)); * * // This cause the first log to run again: * setTimeout(() => $user.name += '!', 1000); * * // This doesn't cause any new logs: * setTimeout(() => rawUser.name += '?', 2000); * * // Both $user and rawUser end up as `{name: 'Frank!?'}` * setTimeout(() => { * console.log('final proxied', $user) * console.log('final unproxied', rawUser) * }, 3000); * ``` */ export function unproxy(target: T): T { return target ? (target as any)[TARGET_SYMBOL] || target : target; } const onDestroyMap: WeakMap void) | true> = new WeakMap(); function destroyWithClass(element: Element, cls: string) { const classes = cls.split(".").filter((c) => c); element.classList.add(...classes); // Backstop, covering animations that never finish and engines without `getAnimations`. const timer = setTimeout(() => element.remove(), 3000); if (!element.getAnimations) return; // Defer to a microtask, so that destroying many elements at once triggers a single // style flush (by `getAnimations`) instead of one per element. queueMicrotask(() => { const remove = () => { clearTimeout(timer); element.remove(); }; // This sees the transitions/animations the class change set in motion, delay // phase and descendants included. If it started none (no transition defined, // or none can run because the element is hidden), remove right away. const animations = element.getAnimations({ subtree: true }); if (animations.length) { Promise.allSettled(animations.map((a) => a.finished)).then(remove); } else { remove(); } }); } /** * Recursively copies properties or array items from `src` to `dst`. * It's designed to work efficiently with reactive proxies created by {@link proxy}. * * - **Minimizes Updates:** When copying between objects/arrays (proxied or not), if a nested object * exists in `dst` with the same constructor as the corresponding object in `src`, `copy` * will recursively copy properties into the existing `dst` object instead of replacing it. * This minimizes change notifications for reactive (proxied) destinations. * - **Fast with Proxies:** When copying to/from proxied objects, `copy` uses Aberdeen internals * to speed things up (compared to a non-Aberdeen-aware deep copy). * * @param dst - The destination object/array/Map (proxied or unproxied). * @param src - The source object/array/Map (proxied or unproxied). It won't be modified. * @template T - The type of the objects being copied. * @returns `true` if any changes were made to `dst`, or `false` if not. * @throws Error if attempting to copy an array into a non-array or vice versa. * * @example Basic Copy * ```typescript * const $source = A.proxy({ a: 1, b: { c: 2 } }); * const $dest = A.proxy({ b: { d: 3 } }); * A.copy($dest, $source); * console.log($dest); // proxy({ a: 1, b: { c: 2 } }) * A.copy($dest, 'b', { e: 4 }); * console.log($dest); // proxy({ a: 1, b: { e: 4 } }) * ``` */ export function copy(dst: T, src: T): boolean; /** * Like above, but copies `src` into `dst[dstKey]`. This is useful if you're unsure if dst[dstKey] * already exists (as the right type of object) or if you don't want to subscribe to dst[dstKey]. * * @param dstKey - Optional key in `dst` to copy into. */ export function copy(dst: T, dstKey: keyof T, src: T[typeof dstKey]): boolean; export function copy(a: any, b: any, c?: any): boolean { if (arguments.length > 2) return copySet(a, b, c, 0); return copyImpl(a, b, 0); } function copySet(dst: any, dstKey: any, src: any, flags: number): boolean { let dstVal = peek(dst, dstKey); if (src === dstVal) return false; if (typeof dstVal === "object" && dstVal !== null && typeof src === "object" && src !== null && dstVal.constructor === src.constructor) { return copyImpl(dstVal, src, flags); } src = clone(src); if (dst instanceof Map) dst.set(dstKey, src); else dst[dstKey] = clone(src); return true; } /** * Like {@link copy}, but uses merge semantics. Properties in `dst` not present in `src` are kept. * `null`/`undefined` in `src` delete properties in `dst`. * * @example Basic merge * ```typescript * const source = { b: { c: 99 }, d: undefined }; // d: undefined will delete * const $dest = A.proxy({ a: 1, b: { x: 5 }, d: 4 }); * A.merge($dest, source); * A.merge($dest, 'b', { y: 6 }); // merge into $dest.b * A.merge($dest, 'c', { z: 7 }); // $dest.c doesn't exist yet, so it will just be assigned * console.log($dest); // proxy({ a: 1, b: { c: 99, x: 5, y: 6 }, c: { z: 7 } }) * ``` * */ export function merge(dst: T, value: Partial): boolean; export function merge(dst: T, dstKey: keyof T, value: Partial): boolean; export function merge(a: any, b: any, c?: any) { if (arguments.length > 2) return copySet(a, b, c, MERGE); return copyImpl(a, b, MERGE); } function copyImpl(dst: any, src: any, flags: number): boolean { // We never want to subscribe to reads we do to the target (to find changes). So we'll // take the unproxied version and `emit` updates ourselve. let unproxied = (dst as any)[TARGET_SYMBOL]; if (unproxied) { dst = unproxied; flags |= COPY_EMIT; } // For performance, we'll work on the unproxied `src` and manually subscribe to changes. unproxied = (src as any)[TARGET_SYMBOL]; if (unproxied) { src = unproxied; // If we're not in peek mode, we'll manually subscribe to all source reads. if (currentScope !== ROOT_SCOPE && !peeking) flags |= COPY_SUBSCRIBE; } return copyRecursive(dst, src, flags); } // The dst and src parameters must be objects. Will throw a friendly message if they're not both the same type. function copyRecursive(dst: T, src: T, flags: number): boolean { if (flags & COPY_SUBSCRIBE) subscribe(src, ANY_SYMBOL); let changed = false; // The following loops are somewhat repetitive, but it keeps performance high by avoiding // function calls and extra checks within the loops. if (src instanceof Array && dst instanceof Array) { const dstLen = dst.length; const srcLen = src.length; for (let index = 0; index < srcLen; index++) { // changed = copyValue(dst, i, src[i], flags) || changed; let dstValue = dst[index]; if (dstValue === undefined && !dst.hasOwnProperty(index)) dstValue = EMPTY; let srcValue = src[index]; if (srcValue === undefined && !src.hasOwnProperty(index)) { delete dst[index]; if (flags & COPY_EMIT) emit(dst, index, EMPTY, dstValue); changed = true; } else if (dstValue !== srcValue) { if (typeof srcValue === "object" && srcValue !== null) { if (typeof dstValue === "object" && dstValue !== null && srcValue.constructor === dstValue.constructor && !(OPAQUE in srcValue)) { changed = copyRecursive(dstValue, srcValue, flags) || changed; continue; } srcValue = cloneRecursive(srcValue, flags & COPY_SUBSCRIBE); } dst[index] = srcValue; if (flags & COPY_EMIT) emit(dst, index, srcValue, dstValue); changed = true; } } // Leaving additional values in the old array doesn't make sense, so we'll do this even when MERGE is set: if (srcLen !== dstLen) { if (flags & COPY_EMIT) { for (let i = srcLen; i < dstLen; i++) { const old = dst[i]; delete dst[i]; emit(dst, i, EMPTY, old); } dst.length = srcLen; emit(dst, "length", srcLen, dstLen); } else { dst.length = srcLen; } changed = true; } } else if (src instanceof Map && dst instanceof Map) { for (const key of src.keys()) { // changed = copyValue(dst, k, src.get(k), flags) || changed; let srcValue = src.get(key); let dstValue = dst.get(key); if (dstValue === undefined && !dst.has(key)) dstValue = EMPTY; if (dstValue !== srcValue) { if (typeof srcValue === "object" && srcValue !== null) { if (typeof dstValue === "object" && dstValue !== null && srcValue.constructor === dstValue.constructor && !(OPAQUE in srcValue)) { changed = copyRecursive(dstValue, srcValue, flags) || changed; continue; } srcValue = cloneRecursive(srcValue, flags & COPY_SUBSCRIBE); } dst.set(key, srcValue); if (flags & COPY_EMIT) emit(dst, key, srcValue, dstValue); changed = true; } } if (!(flags & MERGE)) { for (const k of dst.keys()) { if (!src.has(k)) { const old = dst.get(k); dst.delete(k); if (flags & COPY_EMIT) { emit(dst, k, EMPTY, old); } changed = true; } } } } else if (src.constructor === dst.constructor) { for (const key of Object.keys(src) as (keyof typeof src)[]) { let srcValue = src[key]; const dstValue = dst.hasOwnProperty(key) ? dst[key] : EMPTY; if (dstValue !== srcValue) { if (typeof srcValue === "object" && srcValue !== null) { if (typeof dstValue === "object" && dstValue !== null && srcValue.constructor === dstValue.constructor && !(OPAQUE in srcValue)) { changed = copyRecursive(dstValue as typeof srcValue, srcValue, flags) || changed; continue; } srcValue = cloneRecursive(srcValue, flags & COPY_SUBSCRIBE); } dst[key] = srcValue; if (flags & COPY_EMIT) emit(dst, key, srcValue, dstValue); changed = true; } } if (!(flags & MERGE)) { for (const k of Object.keys(dst) as (keyof typeof dst)[]) { if (!src.hasOwnProperty(k)) { const old = dst[k]; delete dst[k]; if (flags & COPY_EMIT && old !== undefined) { emit(dst, k, EMPTY, old); } changed = true; } } } } else { throw new Error(`Incompatible or non-object types: ${src?.constructor?.name || typeof src} vs ${dst?.constructor?.name || typeof dst}`); } return changed; } const MERGE = 1; const COPY_SUBSCRIBE = 32; const COPY_EMIT = 64; /** * A symbol that controls how Aberdeen handles an object in copy operations and proxy wrapping. * * The **presence** of this symbol (regardless of its value) prevents deep-copying: the object is * stored and passed by reference in {@link clone} and {@link copy}. * * The **value** of the symbol controls proxy wrapping when the object is read from reactive state: * - **Truthy** (e.g. `true`): the object is fully opaque — it is not wrapped in a proxy, so its * properties are not observable. Use this for objects that break when proxied (e.g. class instances * with internal slots, Promises) or that must be invisible to Aberdeen's reactive system. * - **Falsy** (e.g. `false`): the object is still wrapped in a proxy, so reads on its properties * create reactive dependencies as normal — only deep-copying is suppressed. */ export const OPAQUE = Symbol("OPAQUE"); /** * Use {@link OPAQUE} instead. This is an alias kept for backward compatibility. * * @deprecated */ export const NO_COPY = OPAQUE; // Built-in types with 'internal slots' break when proxied. So we'll treat them // as primitive values. (Promise.prototype as any)[OPAQUE] = true; (Date.prototype as any)[OPAQUE] = true; (Node.prototype as any)[OPAQUE] = true; // Same for Temporal types declare const Temporal: any; if (typeof Temporal !== 'undefined') { for(const name of 'Duration Instant PlainDate PlainDateTime PlainMonthDay PlainTime PlainYearMonth ZonedDateTime'.split(' ')) { const cls = Temporal[name]; if (cls) cls.prototype[OPAQUE] = true; } } /** * A reactive object containing CSS variable definitions. * * Any property you assign to `cssVars` becomes available as a CSS custom property throughout your application. * * Use {@link setSpacingCssVars} to optionally initialize `cssVars[1]` through `cssVars[12]` with an exponential spacing scale. * * When you reference a CSS variable in Aberdeen using the `$` prefix (e.g., `$primary`), it automatically resolves to `var(--primary)`. * For numeric keys (which can't be used directly as CSS custom property names), Aberdeen prefixes them with `m` (e.g., `$3` becomes `var(--m3)`). * * When you add the first property to cssVars, Aberdeen automatically creates a reactive `