aberdeen 0.2.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/aberdeen.ts CHANGED
@@ -1,2037 +1,2297 @@
1
-
1
+ import { ReverseSortedSet } from "./helpers/reverseSortedSet.js";
2
2
 
3
3
  /*
4
- * QueueRunner
5
- *
6
- * `queue()`d runners are executed on the next timer tick, by order of their
7
- * `queueOrder` values.
8
- */
4
+ * QueueRunner
5
+ *
6
+ * `queue()`d runners are executed on the next timer tick, by order of their
7
+ * `prio` values.
8
+ */
9
9
  interface QueueRunner {
10
- _queueOrder: number
11
- _queueRun(): void
10
+ prio: number; // Higher values have higher priority
11
+ queueRun(): void;
12
12
  }
13
13
 
14
- let queueArray: Array<QueueRunner> = []
15
- let queueSet: Set<QueueRunner> = new Set()
16
- let queueOrdered = true
17
- let runQueueDepth = 0
18
- let queueIndex: number | undefined
14
+ let sortedQueue: ReverseSortedSet<QueueRunner> | undefined; // When set, a runQueue is scheduled or currently running.
15
+ 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.
16
+ let topRedrawScope: Scope | undefined // The scope that triggered the current redraw. Elements drawn at this scope level may trigger 'create' animations.
19
17
 
20
18
  /** @internal */
21
- export type Patch = Map<ObsCollection, Map<any, [any, any]>>;
19
+ export type TargetType = any[] | {[key: string]: any};
20
+ /** @internal */
21
+ export type DatumType = TargetType | boolean | number | string | null | undefined;
22
22
 
23
23
  function queue(runner: QueueRunner) {
24
- if (queueSet.has(runner)) return
25
- if (runQueueDepth > 42) {
26
- throw new Error("Too many recursive updates from observes")
27
- }
28
- if (!queueArray.length) {
29
- setTimeout(runQueue, 0)
30
- }
31
- else if (runner._queueOrder < queueArray[queueArray.length-1]._queueOrder) {
32
- queueOrdered = false
33
- }
34
- queueArray.push(runner)
35
- queueSet.add(runner)
36
- }
37
-
38
- function runQueue(): void {
39
- onCreateEnabled = true
40
- for(queueIndex = 0; queueIndex < queueArray.length; ) {
41
- // Sort queue if new unordered items have been added since last time.
42
- if (!queueOrdered) {
43
- queueArray.splice(0, queueIndex)
44
- queueIndex = 0
45
- // Order queued observers by depth, lowest first.
46
- queueArray.sort((a,b) => a._queueOrder - b._queueOrder)
47
- queueOrdered = true
48
- }
49
-
50
- // Process the rest of what's currently in the queue.
51
- let batchEndIndex = queueArray.length
52
- for(; queueIndex < batchEndIndex && queueOrdered; queueIndex++) {
53
- let runner = queueArray[queueIndex]
54
- queueSet.delete(runner)
55
- runner._queueRun()
24
+ if (!sortedQueue) {
25
+ sortedQueue = new ReverseSortedSet<QueueRunner>('prio');
26
+ setTimeout(runQueue, 0);
27
+ } else if (!(runQueueDepth&1)) {
28
+ runQueueDepth++; // Make it uneven
29
+ if (runQueueDepth > 98) {
30
+ throw new Error("Too many recursive updates from observes");
56
31
  }
57
-
58
- // If new items have been added to the queue while processing the previous
59
- // batch, we'll need to run this loop again.
60
- runQueueDepth++
61
32
  }
62
-
63
- queueArray.length = 0
64
- queueIndex = undefined
65
- runQueueDepth = 0
66
- onCreateEnabled = false
33
+ sortedQueue.add(runner);
67
34
  }
68
35
 
69
-
70
36
  /**
71
- * Schedule a DOM read operation to be executed in Aberdeen's internal task queue.
72
- *
73
- * This function is used to batch DOM read operations together, avoiding unnecessary
74
- * layout recalculations and improving browser performance. A DOM read operation should
75
- * only *read* from the DOM, such as measuring element dimensions or retrieving computed styles.
37
+ * Forces the immediate and synchronous execution of all pending reactive updates.
76
38
  *
77
- * By batching DOM reads separately from DOM writes, this prevents the browser from
78
- * interleaving layout reads and writes, which can force additional layout recalculations.
79
- * This helps reduce visual glitches and flashes by ensuring the browser doesn't render
80
- * intermediate DOM states during updates.
39
+ * Normally, changes to observed data sources (like proxied objects or arrays)
40
+ * are processed asynchronously in a batch after a brief timeout (0ms). This function
41
+ * allows you to bypass the timeout and process the update queue immediately.
81
42
  *
82
- * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM read
83
- * operations happen before any DOM writes in the same queue cycle, minimizing layout thrashing.
43
+ * This can be useful in specific scenarios where you need the DOM to be updated
44
+ * synchronously.
84
45
  *
85
- * @param func The function to be executed as a DOM read operation.
86
- */
87
- export function scheduleDomReader(func: () => void): void {
88
- let order = (queueIndex!=null && queueIndex < queueArray.length && queueArray[queueIndex]._queueOrder >= 1000) ? ((queueArray[queueIndex]._queueOrder+1) & (~1)) : 1000
89
- queue({_queueOrder: order, _queueRun: func})
90
- }
91
-
92
- /**
93
- * Schedule a DOM write operation to be executed in Aberdeen's internal task queue.
46
+ * This function is re-entrant, meaning it is safe to call `runQueue` from within
47
+ * a function that is itself being executed as part of an update cycle triggered
48
+ * by a previous (or the same) `runQueue` call.
94
49
  *
95
- * This function is used to batch DOM write operations together, avoiding unnecessary
96
- * layout recalculations and improving browser performance. A DOM write operation should
97
- * only *write* to the DOM, such as modifying element properties or applying styles.
98
- *
99
- * By batching DOM writes separately from DOM reads, this prevents the browser from
100
- * interleaving layout reads and writes, which can force additional layout recalculations.
101
- * This helps reduce visual glitches and flashes by ensuring the browser doesn't render
102
- * intermediate DOM states during updates.
50
+ * @example
51
+ * ```typescript
52
+ * const data = proxy("before");
53
+ *
54
+ * $({text: data});
55
+ * console.log(1, document.body.innerHTML); // before
103
56
  *
104
- * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM write
105
- * operations happen after all DOM reads in the same queue cycle, minimizing layout thrashing.
57
+ * // Make an update that should cause the DOM to change.
58
+ * data.value = "after";
106
59
  *
107
- * @param func The function to be executed as a DOM write operation.
60
+ * // Normally, the DOM update would happen after a timeout.
61
+ * // But this causes an immediate update:
62
+ * runQueue();
63
+ *
64
+ * console.log(2, document.body.innerHTML); // after
65
+ * ```
108
66
  */
109
- export function scheduleDomWriter(func: () => void): void {
110
- let order = (queueIndex!=null && queueIndex < queueArray.length && queueArray[queueIndex]._queueOrder >= 1000) ? (queueArray[queueIndex]._queueOrder | 1) : 1001
111
- queue({_queueOrder: order, _queueRun: func})
67
+ export function runQueue(): void {
68
+ let time = Date.now();
69
+ while(sortedQueue) {
70
+ const runner = sortedQueue.fetchLast();
71
+ if (!runner) break;
72
+ if (runQueueDepth&1) runQueueDepth++; // Make it even
73
+ runner.queueRun();
74
+ }
75
+ sortedQueue = undefined;
76
+ runQueueDepth = 0;
77
+ time = Date.now() - time;
78
+ if (time>1) console.debug(`Aberdeen queue took ${time}ms`);
112
79
  }
113
80
 
114
81
 
115
- /** @internal */
116
- type SortKeyType = number | string | Array<number|string>
117
-
118
-
119
82
  /**
120
- * Given an integer number, a string or an array of these, this function returns a string that can be used
121
- * to compare items in a natural sorting order. So `[3, 'ab']` should be smaller than `[3, 'ac']`.
122
- * The resulting string is guaranteed to never be empty.
83
+ * A sort key, as used by {@link onEach}, is a value that determines the order of items. It can
84
+ * be a number, string, or an array of numbers/strings. The sort key is used to sort items
85
+ * based on their values. The sort key can also be `undefined`, which indicates that the item
86
+ * should be ignored.
87
+ * @private
123
88
  */
124
- function sortKeyToString(key: SortKeyType) {
125
- if (key instanceof Array) {
126
- return key.map(partToStr).join('')
127
- } else {
128
- return partToStr(key)
129
- }
130
- }
89
+ export type SortKeyType = number | string | Array<number|string> | undefined;
131
90
 
91
+ /**
92
+ * Given an integer number or a string, this function returns a string that can be concatenated
93
+ * with other strings to create a composed sort key, that follows natural number ordering.
94
+ */
132
95
  function partToStr(part: number|string): string {
133
96
  if (typeof part === 'string') {
134
- return part + '\x01'
135
- } else {
136
- let result = numToString(Math.abs(Math.round(part)), part<0)
137
- // Prefix the number of digits, counting down from 128 for negative and up for positive
138
- return String.fromCharCode(128 + (part>0 ? result.length : -result.length)) + result
97
+ return part + '\x01'; // end-of-string
139
98
  }
140
- }
141
-
142
- function numToString(num: number, neg: boolean): string {
143
- let result = ''
99
+ let result = '';
100
+ let num = Math.abs(Math.round(part));
101
+ const negative = part < 0;
144
102
  while(num > 0) {
145
103
  /*
146
104
  * We're reserving a few character codes:
147
105
  * 0 - for compatibility
148
- * 1 - separator between array items
106
+ * 1 - separator between string array items
149
107
  * 65535 - for compatibility
150
108
  */
151
- result += String.fromCharCode(neg ? 65535 - (num % 65533) : 2 + (num % 65533))
152
- num = Math.floor(num / 65533)
109
+ result += String.fromCharCode(negative ? 65534 - (num % 65533) : 2 + (num % 65533));
110
+ num = Math.floor(num / 65533);
153
111
  }
154
- return result
112
+ // Prefix the number of digits, counting down from 128 for negative and up for positive
113
+ return String.fromCharCode(128 + (negative ? -result.length : result.length)) + result;
155
114
  }
156
115
 
157
- /** @internal */
158
- interface Observer {
159
- _onChange(index: any, newData: DatumType, oldData: DatumType): void
116
+ /**
117
+ * Creates a new string that has the opposite sort order compared to the input string.
118
+ *
119
+ * This is achieved by flipping the bits of each character code in the input string.
120
+ * The resulting string is intended for use as a sort key, particularly with the
121
+ * `makeKey` function in {@link onEach}, to achieve a descending sort order.
122
+ *
123
+ * **Warning:** The output string will likely contain non-printable characters or
124
+ * appear as gibberish and should not be displayed to the user.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const users = proxy([
129
+ * { id: 1, name: 'Charlie', score: 95 },
130
+ * { id: 2, name: 'Alice', score: 100 },
131
+ * { id: 3, name: 'Bob', score: 90 },
132
+ * ]);
133
+ *
134
+ * onEach(users, (user) => {
135
+ * $(`p:${user.name}: ${user.score}`);
136
+ * }, (user) => invertString(user.name)); // Reverse alphabetic order
137
+ * ```
138
+ *
139
+ * @param input The string whose sort order needs to be inverted.
140
+ * @returns A new string that will sort in the reverse order of the input string.
141
+ * @see {@link onEach} for usage with sorting.
142
+ */
143
+ export function invertString(input: string): string {
144
+ let result = '';
145
+ for (let i = 0; i < input.length; i++) {
146
+ result += String.fromCodePoint(65535 - input.charCodeAt(i));
147
+ }
148
+ return result;
160
149
  }
161
150
 
162
- /*
163
- * Scope
164
- * @internal
165
- *
166
- * A `Scope` is created with a `render` function that is run initially,
167
- * and again when any of the `Store`s that this function reads are changed. Any
168
- * DOM elements that is given a `render` function for its contents has its own scope.
169
- * The `Scope` manages the position in the DOM tree elements created by `render`
170
- * are inserted at. Before a rerender, all previously created elements are removed
171
- * and the `clean` functions for the scope and all sub-scopes are called.
172
- */
173
151
 
174
- abstract class Scope implements QueueRunner, Observer {
175
- _parentElement: Element | undefined
152
+ // Each new scope gets a lower prio than all scopes before it, by decrementing
153
+ // this counter.
154
+ let lastPrio = 0;
155
+
156
+ abstract class Scope implements QueueRunner {
157
+ // Scopes are to be handled in creation order. This will make sure that parents are
158
+ // handled before their children (as they should), and observes are executed in the
159
+ // order of the source code.
160
+ prio: number = --lastPrio;
176
161
 
177
- // How deep is this scope nested in other scopes; we use this to make sure events
178
- // at lower depths are handled before events at higher depths.
179
- _queueOrder: number
162
+ abstract onChange(index: any, newData: DatumType, oldData: DatumType): void;
163
+ abstract queueRun(): void;
180
164
 
181
- // The node or scope right before this scope that has the same `parentElement`
182
- _precedingSibling: Node | Scope | undefined
165
+ abstract getLastNode(): Node | undefined;
166
+ abstract getPrecedingNode(): Node | undefined;
167
+ abstract delete(): void;
183
168
 
184
- // The last child node or scope within this scope that has the same `parentElement`
185
- _lastChild: Node | Scope | undefined
169
+ remove() {
170
+ // Remove any nodes
171
+ const lastNode = this.getLastNode();
172
+ if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
186
173
 
174
+ // Run any cleaners
175
+ this.delete();
176
+ }
177
+
178
+ // toString(): string {
179
+ // return `${this.constructor.name}`
180
+ // }
181
+ }
182
+
183
+ /**
184
+ * All Scopes that can hold nodes and subscopes, including `SimpleScope` and `OnEachItemScope`
185
+ * but *not* `OnEachScope`, are `ContentScope`s.
186
+ */
187
+ abstract class ContentScope extends Scope {
187
188
  // The list of clean functions to be called when this scope is cleaned. These can
188
189
  // be for child scopes, subscriptions as well as `clean(..)` hooks.
189
- _cleaners: Array<{_clean: (scope: Scope) => void}> = []
190
+ cleaners: Array<{delete: (scope: Scope) => void} | (() => void)>;
190
191
 
191
- // Set to true after the scope has been cleaned, causing any spurious reruns to
192
- // be ignored.
193
- _isDead: boolean = false
194
-
195
- constructor(
196
- parentElement: Element | undefined,
197
- precedingSibling: Node | Scope | undefined,
198
- queueOrder: number,
199
- ) {
200
- this._parentElement = parentElement
201
- this._precedingSibling = precedingSibling
202
- this._queueOrder = queueOrder
203
- }
204
-
205
- // Get a reference to the last Node preceding this Scope, or undefined if there is none
206
- _findPrecedingNode(stopAt: Scope | Node | undefined = undefined): Node | undefined {
207
- let cur: Scope = this
208
- let pre: Scope | Node | undefined
209
- while((pre = cur._precedingSibling) && pre !== stopAt) {
210
- if (pre instanceof Node) return pre
211
- let node = pre._findLastNode()
212
- if (node) return node
213
- cur = pre
214
- }
192
+ constructor(cleaners: Array<{delete: (scope: Scope) => void} | (() => void)> = []) {
193
+ super();
194
+ this.cleaners = cleaners;
215
195
  }
216
196
 
217
- // Get a reference to the last Node within this scope and parentElement
218
- _findLastNode(): Node | undefined {
219
- if (this._lastChild) {
220
- if (this._lastChild instanceof Node) return this._lastChild
221
- else return this._lastChild._findLastNode() || this._lastChild._findPrecedingNode(this._precedingSibling)
222
- }
223
- }
197
+ lastChild: Node | Scope | undefined;
224
198
 
225
- _addNode(node: Node) {
226
- if (!this._parentElement) throw new ScopeError(true)
227
- let prevNode = this._findLastNode() || this._findPrecedingNode()
199
+ // Should be subclassed in most cases..
200
+ redraw() {};
228
201
 
229
- this._parentElement.insertBefore(node, prevNode ? prevNode.nextSibling : this._parentElement.firstChild)
230
- this._lastChild = node
231
- }
202
+ abstract parentElement: Element;
232
203
 
233
- _remove() {
234
- if (this._parentElement) {
235
- let lastNode: Node | undefined = this._findLastNode()
236
- if (lastNode) {
237
- // at least one DOM node to be removed
238
-
239
- let nextNode: Node | undefined = this._findPrecedingNode()
240
- nextNode = (nextNode ? nextNode.nextSibling : this._parentElement.firstChild) as Node | undefined
204
+ getLastNode(): Node | undefined {
205
+ return findLastNodeInPrevSiblings(this.lastChild);
206
+ }
241
207
 
242
- this._lastChild = undefined
243
-
244
- // Keep removing DOM nodes starting at our first node, until we encounter the last node
245
- while(true) {
246
- /* c8 ignore next */
247
- if (!nextNode) return internalError(1)
248
-
249
- const node = nextNode
250
- nextNode = node.nextSibling || undefined
251
- let onDestroy = onDestroyMap.get(node)
252
- if (onDestroy && node instanceof Element) {
253
- if (onDestroy !== true) {
254
- if (typeof onDestroy === 'function') {
255
- onDestroy(node)
256
- } else {
257
- destroyWithClass(node, onDestroy)
258
- }
259
- // This causes the element to be ignored from this function from now on:
260
- onDestroyMap.set(node, true)
261
- }
262
- // Ignore the deleting element
263
- } else {
264
- this._parentElement.removeChild(node)
265
- }
266
- if (node === lastNode) break
267
- }
268
- }
208
+ /**
209
+ * Call cleaners and make sure the scope is not queued.
210
+ * It is called `delete`, so that the list of cleaners can also contain `Set`s.
211
+ */
212
+ delete(/* ignore observer argument */) {
213
+ for(let cleaner of this.cleaners) {
214
+ if (typeof cleaner === 'function') cleaner();
215
+ else cleaner.delete(this); // pass in observer argument, in case `cleaner` is a `Set`
269
216
  }
217
+ this.cleaners.length = 0;
218
+ sortedQueue?.remove(this); // This is very fast and O(1) when not queued
270
219
 
271
- // run cleaners
272
- this._clean()
220
+ // To prepare for a redraw or to help GC when we're being removed:
221
+ this.lastChild = undefined;
222
+ }
223
+
224
+ queueRun() {
225
+ this.remove();
226
+
227
+ topRedrawScope = this
228
+ this.redraw();
229
+ topRedrawScope = undefined
273
230
  }
274
231
 
275
- _clean() {
276
- this._isDead = true
277
- for(let cleaner of this._cleaners) {
278
- cleaner._clean(this)
279
- }
280
- this._cleaners.length = 0
232
+ getInsertAfterNode() {
233
+ return this.getLastNode() || this.getPrecedingNode();
281
234
  }
282
235
 
283
- _onChange(index: any, newData: DatumType, oldData: DatumType) {
284
- queue(this)
236
+ onChange(index: any, newData: DatumType, oldData: DatumType) {
237
+ queue(this);
285
238
  }
286
239
 
287
- abstract _queueRun(): void
240
+ getChildPrevSibling() {
241
+ return this.lastChild;
242
+ }
288
243
  }
289
244
 
290
- class SimpleScope extends Scope {
291
- _renderer: () => void
245
+
246
+ class ChainedScope extends ContentScope {
247
+ // The node or scope right before this scope that has the same `parentElement`.
248
+ public prevSibling: Node | Scope | undefined;
292
249
 
293
250
  constructor(
294
- parentElement: Element | undefined,
295
- precedingSibling: Node | Scope | undefined,
296
- queueOrder: number,
297
- renderer: () => void,
251
+ // The parent DOM element we'll add our child nodes to.
252
+ public parentElement: Element,
253
+ // When true, we share our 'cleaners' list with the parent scope.
254
+ useParentCleaners: boolean = false,
298
255
  ) {
299
- super(parentElement, precedingSibling, queueOrder)
300
- this._renderer = renderer
256
+ super(useParentCleaners ? currentScope.cleaners : []);
257
+ if (parentElement === currentScope.parentElement) {
258
+ // If `currentScope` is not actually a ChainedScope, prevSibling will be undefined, as intended
259
+ this.prevSibling = currentScope.getChildPrevSibling();
260
+ currentScope.lastChild = this;
261
+ }
262
+
263
+ // We're always adding ourselve as a cleaner, in order to run our own cleaners
264
+ // and to remove ourselve from the queue (if we happen to be in there).
265
+ if (!useParentCleaners) currentScope.cleaners.push(this);
301
266
  }
302
267
 
303
- _queueRun() {
304
- /* c8 ignore next */
305
- if (currentScope) internalError(2)
268
+ getPrecedingNode(): Node | undefined {
269
+ return findLastNodeInPrevSiblings(this.prevSibling);
270
+ }
271
+
272
+ getChildPrevSibling() {
273
+ return this.lastChild || this.prevSibling;
274
+ }
275
+ }
306
276
 
307
- if (this._isDead) return
308
- this._remove()
309
- this._isDead = false
277
+ /**
278
+ * @internal
279
+ * A `RegularScope` is created with a `render` function that is run initially,
280
+ * and again when any of the `Store`s that this function reads are changed. Any
281
+ * DOM elements that is given a `render` function for its contents has its own scope.
282
+ * The `Scope` manages the position in the DOM tree elements created by `render`
283
+ * are inserted at. Before a rerender, all previously created elements are removed
284
+ * and the `clean` functions for the scope and all sub-scopes are called.
285
+ */
286
+ class RegularScope extends ChainedScope {
287
+ constructor(
288
+ parentElement: Element,
289
+ // The function that will be reactively called. Elements it creates using `$` are
290
+ // added to the appropriate position within `parentElement`.
291
+ public renderer: () => any,
292
+ ) {
293
+ super(parentElement);
310
294
 
311
- this._update()
295
+ // Do the initial run
296
+ this.redraw();
312
297
  }
313
298
 
314
- _update() {
315
- let savedScope = currentScope
316
- currentScope = this
299
+ redraw() {
300
+ let savedScope = currentScope;
301
+ currentScope = this;
317
302
  try {
318
- this._renderer()
303
+ this.renderer();
319
304
  } catch(e) {
320
305
  // Throw the error async, so the rest of the rendering can continue
321
- handleError(e)
306
+ handleError(e, true);
322
307
  }
323
- currentScope = savedScope
308
+ currentScope = savedScope;
324
309
  }
325
310
  }
326
311
 
327
- let immediateQueue: Set<Scope> = new Set()
328
312
 
329
- class ImmediateScope extends SimpleScope {
330
- _onChange(index: any, newData: DatumType, oldData: DatumType) {
331
- immediateQueue.add(this)
313
+ class RootScope extends ContentScope {
314
+ parentElement = document.body;
315
+ getPrecedingNode(): Node | undefined {
316
+ return undefined;
332
317
  }
333
318
  }
334
319
 
335
- let immediateQueuerRunning = false
336
- function runImmediateQueue() {
337
- if (immediateQueuerRunning) return
338
- for(let count=0; immediateQueue.size; count++) {
339
- if (count > 42) {
340
- immediateQueue.clear()
341
- throw new Error("Too many recursive updates from immediate-mode observes")
342
- }
343
- immediateQueuerRunning = true
344
- let copy = immediateQueue
345
- immediateQueue = new Set()
346
- let savedScope = currentScope
347
- currentScope = undefined
348
- try {
349
- for(const scope of copy) {
350
- scope._queueRun()
351
- }
352
- } finally {
353
- currentScope = savedScope
354
- immediateQueuerRunning = false
355
- }
320
+ class MountScope extends ContentScope {
321
+ constructor(
322
+ // The parent DOM element we'll add our child nodes to
323
+ public parentElement: Element,
324
+ // The function that
325
+ public renderer: () => any,
326
+ ) {
327
+ super();
328
+ this.redraw();
329
+ currentScope.cleaners.push(this)
356
330
  }
357
- }
358
331
 
359
- class IsEmptyObserver implements Observer {
360
- scope: Scope
361
- collection: ObsCollection
362
- count: number
363
- triggerCount: boolean
332
+ redraw() {
333
+ RegularScope.prototype.redraw.call(this);
334
+ }
364
335
 
365
- constructor(scope: Scope, collection: ObsCollection, triggerCount: boolean) {
366
- this.scope = scope
367
- this.collection = collection
368
- this.triggerCount = triggerCount
369
- this.count = collection._getCount()
336
+ getPrecedingNode(): Node | undefined {
337
+ return undefined;
338
+ }
370
339
 
371
- collection._addObserver(ANY_INDEX, this)
372
- scope._cleaners.push(this)
340
+ delete() {
341
+ // We can't rely on our parent scope to remove all our nodes for us, as our parent
342
+ // probably has a totally different `parentElement`. Therefore, our `delete()` does
343
+ // what `_remove()` does for regular scopes.
344
+ removeNodes(this.getLastNode(), this.getPrecedingNode());
345
+ super.delete();
373
346
  }
374
347
 
375
- _onChange(index: any, newData: DatumType, oldData: DatumType) {
376
- if (newData===undefined) {
377
- // oldData is guaranteed not to be undefined
378
- if (this.triggerCount || !--this.count) queue(this.scope)
379
- } else if (oldData===undefined) {
380
- if (this.triggerCount || !this.count++) queue(this.scope)
381
- }
348
+ remove() {
349
+ this.delete();
382
350
  }
351
+ }
352
+
383
353
 
384
- _clean() {
385
- this.collection._removeObserver(ANY_INDEX, this)
354
+ // Remove node and all its preceding siblings (uptil and excluding preNode)
355
+ // from the DOM, using onDestroy if applicable.
356
+ function removeNodes(node: Node | null | undefined, preNode: Node | null | undefined) {
357
+ while(node && node !== preNode) {
358
+ const prevNode: Node | null = node.previousSibling;
359
+ let onDestroy = onDestroyMap.get(node);
360
+ if (onDestroy && node instanceof Element) {
361
+ if (onDestroy !== true) {
362
+ if (typeof onDestroy === 'function') {
363
+ onDestroy(node);
364
+ } else {
365
+ destroyWithClass(node, onDestroy);
366
+ }
367
+ // This causes the element to be ignored from this function from now on:
368
+ onDestroyMap.set(node, true);
369
+ }
370
+ // Ignore the deleting element
371
+ } else {
372
+ (node as Element|Text).remove();
373
+ }
374
+ node = prevNode;
386
375
  }
387
376
  }
388
377
 
389
- /** @internal */
390
- class OnEachScope extends Scope {
378
+ // Get a reference to the last node within `sibling` or any of its preceding siblings.
379
+ // If a `Node` is given, that node is returned.
380
+ function findLastNodeInPrevSiblings(sibling: Node | Scope | undefined): Node | undefined {
381
+ if (!sibling || sibling instanceof Node) return sibling;
382
+ return sibling.getLastNode() || sibling.getPrecedingNode();
383
+ }
391
384
 
392
- /** The Node we are iterating */
393
- _collection: ObsCollection
394
385
 
395
- /** A function returning a number/string/array that defines the position of an item */
396
- _makeSortKey: (value: Store) => SortKeyType
386
+ class ResultScope<T extends DatumType | void> extends ChainedScope {
387
+ public result: ValueRef<T> = optProxy({value: undefined});
397
388
 
398
- /** A function that renders an item */
399
- _renderer: (itemStore: Store) => void
389
+ constructor(
390
+ parentElement: Element,
391
+ public renderer: () => T,
392
+ ) {
393
+ super(parentElement);
400
394
 
401
- /** The ordered list of currently item scopes */
402
- _byPosition: OnEachItemScope[] = []
395
+ this.redraw();
396
+ }
403
397
 
404
- /** The item scopes in a Map by index */
405
- _byIndex: Map<any, OnEachItemScope> = new Map()
398
+ redraw() {
399
+ let savedScope = currentScope;
400
+ currentScope = this;
401
+ try {
402
+ this.result.value = this.renderer();
403
+ } catch(e) {
404
+ // Throw the error async, so the rest of the rendering can continue
405
+ handleError(e, true);
406
+ }
407
+ currentScope = savedScope;
408
+ }
409
+ }
406
410
 
407
- /** Indexes that have been created/removed and need to be handled in the next `queueRun` */
408
- _newIndexes: Set<any> = new Set()
409
- _removedIndexes: Set<any> = new Set()
411
+ /**
412
+ * A `Scope` subclass optimized for reactively setting just a single element property
413
+ * based on a proxied reference.
414
+ */
410
415
 
416
+ class SetArgScope extends ChainedScope {
411
417
  constructor(
412
- parentElement: Element | undefined,
413
- precedingSibling: Node | Scope | undefined,
414
- queueOrder: number,
415
- collection: ObsCollection,
416
- renderer: (itemStore: Store) => void,
417
- makeSortKey: (itemStore: Store) => SortKeyType
418
+ parentElement: Element,
419
+ public key: string,
420
+ public target: {value: DatumType},
418
421
  ) {
419
- super(parentElement, precedingSibling, queueOrder)
420
- this._collection = collection
421
- this._renderer = renderer
422
- this._makeSortKey = makeSortKey
422
+ super(parentElement);
423
+ this.redraw();
424
+ }
425
+ redraw() {
426
+ let savedScope = currentScope;
427
+ currentScope = this;
428
+ applyArg(this.key, this.target.value)
429
+ currentScope = savedScope;
423
430
  }
431
+ }
424
432
 
425
- // toString(): string {
426
- // return `OnEachScope(collection=${this.collection})`
427
- // }
428
433
 
429
- _onChange(index: any, newData: DatumType, oldData: DatumType) {
430
- if (oldData===undefined) {
431
- if (this._removedIndexes.has(index)) {
432
- this._removedIndexes.delete(index)
433
- } else {
434
- this._newIndexes.add(index)
435
- queue(this)
436
- }
437
- } else if (newData===undefined) {
438
- if (this._newIndexes.has(index)) {
439
- this._newIndexes.delete(index)
440
- } else {
441
- this._removedIndexes.add(index)
442
- queue(this)
434
+ let immediateQueue: ReverseSortedSet<Scope> = new ReverseSortedSet('prio');
435
+
436
+ class ImmediateScope extends RegularScope {
437
+ onChange(index: any, newData: DatumType, oldData: DatumType) {
438
+ immediateQueue.add(this);
439
+ }
440
+ }
441
+
442
+ let immediateQueueRunning = false;
443
+ function runImmediateQueue() {
444
+ for(let count=0; !immediateQueue.isEmpty() && !immediateQueueRunning; count++) {
445
+ if (count > 42) {
446
+ immediateQueue.clear();
447
+ throw new Error("Too many immediate-mode recursive updates");
448
+ }
449
+ immediateQueueRunning = true;
450
+ let copy = immediateQueue;
451
+ immediateQueue = new ReverseSortedSet('prio');
452
+ try {
453
+ for(const scope of copy) {
454
+ // On exception, the exception will be bubbled up to the call site, discarding any
455
+ // remaining immediate scopes from the queue. This behavior is perhaps debatable,
456
+ // but getting a synchronous exception at the call site can be very helpful.
457
+ scope.queueRun();
443
458
  }
459
+ } finally {
460
+ immediateQueueRunning = false;
444
461
  }
445
462
  }
463
+ }
446
464
 
447
- _queueRun() {
448
- if (this._isDead) return
449
465
 
450
- let indexes = this._removedIndexes
451
- this._removedIndexes = new Set()
452
- indexes.forEach(index => {
453
- this._removeChild(index)
454
- })
466
+ /** @internal */
467
+ class OnEachScope extends Scope {
468
+ parentElement: Element = currentScope.parentElement;
469
+ prevSibling: Node | Scope | undefined;
455
470
 
456
- indexes = this._newIndexes
457
- this._newIndexes = new Set()
458
- indexes.forEach(index => {
459
- this._addChild(index)
460
- })
461
- }
471
+ /** The data structure we are iterating */
472
+ target: TargetType;
473
+
474
+ /** All item scopes, by array index or object key. This is used for removing an item scope when its value
475
+ * disappears, and calling all subscope cleaners. */
476
+ byIndex: Map<any,OnEachItemScope> = new Map();
462
477
 
463
- _clean() {
464
- super._clean()
465
- this._collection._observers.delete(this)
466
- for (const [index, scope] of this._byIndex) {
467
- scope._clean()
468
- }
478
+ /** The reverse-ordered list of item scopes, not including those for which makeSortKey returned undefined. */
479
+ sortedSet: ReverseSortedSet<OnEachItemScope> = new ReverseSortedSet('sortKey');
469
480
 
470
- // Help garbage collection:
471
- this._byPosition.length = 0
472
- this._byIndex.clear()
473
- }
481
+ /** Indexes that have been created/removed and need to be handled in the next `queueRun`. */
482
+ changedIndexes: Set<any> = new Set();
483
+
484
+ constructor(
485
+ proxy: TargetType,
486
+ /** A function that renders an item */
487
+ public renderer: (value: DatumType, key: any, ) => void,
488
+ /** A function returning a number/string/array that defines the position of an item */
489
+ public makeSortKey?: (value: DatumType, key: any) => SortKeyType,
490
+ ) {
491
+ super();
492
+ const target: TargetType = this.target = (proxy as any)[TARGET_SYMBOL] || proxy;
474
493
 
475
- _renderInitial() {
476
- /* c8 ignore next */
477
- if (!currentScope) return internalError(3)
478
- let parentScope = currentScope
494
+ subscribe(target, ANY_SYMBOL, this);
495
+ this.prevSibling = currentScope.getChildPrevSibling();
496
+ currentScope.lastChild = this;
479
497
 
480
- this._collection._iterateIndexes(this)
498
+ currentScope.cleaners.push(this);
481
499
 
482
- currentScope = parentScope
500
+ // Do _addChild() calls for initial items
501
+ if (target instanceof Array) {
502
+ for(let i=0; i<target.length; i++) {
503
+ if (target[i]!==undefined) {
504
+ new OnEachItemScope(this, i, false);
505
+ }
506
+ }
507
+ } else {
508
+ for(const key in target) {
509
+ if (target[key] !== undefined) {
510
+ new OnEachItemScope(this, key, false);
511
+ }
512
+ }
513
+ }
483
514
  }
484
515
 
485
- _addChild(itemIndex: any) {
486
- let scope = new OnEachItemScope(this._parentElement, undefined, this._queueOrder+1, this, itemIndex)
487
- this._byIndex.set(itemIndex, scope)
488
- scope._update()
489
- // We're not adding a cleaner here, as we'll be calling them from our _clean function
516
+ getPrecedingNode(): Node | undefined {
517
+ return findLastNodeInPrevSiblings(this.prevSibling);
490
518
  }
491
-
492
- _removeChild(itemIndex: any) {
493
- let scope = this._byIndex.get(itemIndex)
494
- /* c8 ignore next */
495
- if (!scope) return internalError(6)
496
- scope._remove()
497
- this._byIndex.delete(itemIndex)
498
- this._removeFromPosition(scope)
519
+
520
+ onChange(index: any, newData: DatumType, oldData: DatumType) {
521
+ if (!(this.target instanceof Array) || typeof index === 'number') this.changedIndexes.add(index);
522
+ queue(this);
499
523
  }
500
-
501
- _findPosition(sortStr: string) {
502
- // In case of duplicate `sortStr`s, this will return the first match.
503
- let items = this._byPosition
504
- let min = 0, max = items.length
505
-
506
- // Fast-path for elements that are already ordered (as is the case when working with arrays ordered by index)
507
- if (!max || sortStr > items[max-1]._sortStr) return max
508
-
509
- // Binary search for the insert position
510
- while(min<max) {
511
- let mid = (min+max)>>1
512
- if (items[mid]._sortStr < sortStr) {
513
- min = mid+1
524
+
525
+ queueRun() {
526
+ let indexes = this.changedIndexes;
527
+ this.changedIndexes = new Set();
528
+ for(let index of indexes) {
529
+ const oldScope = this.byIndex.get(index);
530
+ if (oldScope) oldScope.remove();
531
+
532
+ if ((this.target as any)[index] === undefined) {
533
+ this.byIndex.delete(index);
514
534
  } else {
515
- max = mid
535
+ new OnEachItemScope(this, index, true);
516
536
  }
517
537
  }
518
- return min
538
+ topRedrawScope = undefined;
519
539
  }
520
-
521
- _insertAtPosition(child: OnEachItemScope) {
522
- let pos = this._findPosition(child._sortStr)
523
- this._byPosition.splice(pos, 0, child)
524
-
525
- // Based on the position in the list, set the precedingSibling for the new Scope
526
- // and for the next sibling.
527
- let nextSibling: OnEachItemScope = this._byPosition[pos+1]
528
- if (nextSibling) {
529
- child._precedingSibling = nextSibling._precedingSibling
530
- nextSibling._precedingSibling = child
531
- } else {
532
- child._precedingSibling = this._lastChild || this._precedingSibling
533
- this._lastChild = child
540
+
541
+ delete() {
542
+ // Propagate to all our subscopes
543
+ for (const scope of this.byIndex.values()) {
544
+ scope.delete();
534
545
  }
546
+
547
+ // Help garbage collection:
548
+ this.byIndex.clear();
549
+ setTimeout(() => {
550
+ // Unsure if this is a good idea. It takes time, but presumably makes things a lot easier for GC...
551
+ this.sortedSet.clear();
552
+ }, 1);
535
553
  }
536
-
537
- _removeFromPosition(child: OnEachItemScope) {
538
- if (child._sortStr==='') return
539
- let pos = this._findPosition(child._sortStr)
540
- while(true) {
541
- if (this._byPosition[pos] === child) {
542
- // Yep, this is the right scope
543
- this._byPosition.splice(pos, 1)
544
- if (pos < this._byPosition.length) {
545
- let nextSibling: Scope | undefined = this._byPosition[pos] as (Scope | undefined)
546
- /* c8 ignore next */
547
- if (!nextSibling) return internalError(8)
548
- /* c8 ignore next */
549
- if (nextSibling._precedingSibling !== child) return internalError(13)
550
- nextSibling._precedingSibling = child._precedingSibling
551
- } else {
552
- /* c8 ignore next */
553
- if (child !== this._lastChild) return internalError(12)
554
- this._lastChild = child._precedingSibling === this._precedingSibling ? undefined : child._precedingSibling
555
- }
556
- return
557
- }
558
- // There may be another Scope with the same sortStr
559
- /* c8 ignore next */
560
- if (++pos >= this._byPosition.length || this._byPosition[pos]._sortStr !== child._sortStr) return internalError(5)
554
+
555
+ getLastNode(): Node | undefined {
556
+ for(let scope of this.sortedSet) { // Iterates starting at last child scope.
557
+ const node = scope.getActualLastNode();
558
+ if (node) return node;
561
559
  }
562
560
  }
563
561
  }
564
562
 
565
563
  /** @internal */
566
- class OnEachItemScope extends Scope {
567
- _parent: OnEachScope
568
- _itemIndex: any
569
- _sortStr: string = ""
570
-
564
+ class OnEachItemScope extends ContentScope {
565
+ sortKey: string | number | undefined; // When undefined, this scope is currently not showing in the list
566
+ public parentElement: Element;
567
+
571
568
  constructor(
572
- parentElement: Element | undefined,
573
- precedingSibling: Node | Scope | undefined,
574
- queueOrder: number,
575
- parent: OnEachScope,
576
- itemIndex: any
569
+ public parent: OnEachScope,
570
+ public itemIndex: any,
571
+ topRedraw: boolean,
577
572
  ) {
578
- super(parentElement, precedingSibling, queueOrder)
579
- this._parent = parent
580
- this._itemIndex = itemIndex
581
- }
573
+ super();
574
+ this.parentElement = parent.parentElement;
582
575
 
583
- // toString(): string {
584
- // return `OnEachItemScope(itemIndex=${this.itemIndex} parentElement=${this.parentElement} parent=${this.parent} precedingSibling=${this.precedingSibling} lastChild=${this.lastChild})`
585
- // }
576
+ this.parent.byIndex.set(this.itemIndex, this);
586
577
 
587
- _queueRun() {
588
- /* c8 ignore next */
589
- if (currentScope) internalError(4)
578
+ // Okay, this is hacky. In case our first (actual) child is a ChainedScope, we won't be able
579
+ // to provide it with a reliable prevSibling. Therefore, we'll pretend to be that sibling,
580
+ // doing what's need for this case in `getLastNode`.
581
+ // For performance, we prefer not having to create additional 'fake sibling' objects for each item.
582
+ this.lastChild = this;
590
583
 
591
- if (this._isDead) return
592
- this._remove()
593
- this._isDead = false
584
+ // Don't register to be cleaned by parent scope, as the OnEachScope will manage this for us (for efficiency)
594
585
 
595
- this._update()
586
+ if (topRedraw) topRedrawScope = this;
587
+ this.redraw();
596
588
  }
597
589
 
598
- _update() {
599
- // Have the makeSortKey function return an ordering int/string/array.
600
- // Since makeSortKey may get() the Store, we'll need to set currentScope first.
601
- let savedScope = currentScope
602
- currentScope = this
603
-
604
- let itemStore = new Store(this._parent._collection, this._itemIndex)
590
+ getPrecedingNode(): Node | undefined {
591
+ // As apparently we're interested in the node insert position, we'll need to become part
592
+ // of the sortedSet now (if we weren't already).
593
+ // This will do nothing and barely take any time of `this` is already part of the set:
594
+ this.parent.sortedSet.add(this);
595
+
596
+ const preScope = this.parent.sortedSet.prev(this);
597
+ // As preScope should have inserted itself as its first child, this should
598
+ // recursively call getPrecedingNode() on preScope in case it doesn't
599
+ // have any actual nodes as children yet.
600
+ if (preScope) return findLastNodeInPrevSiblings(preScope.lastChild);
601
+ return this.parent.getPrecedingNode();
602
+ }
605
603
 
606
- let sortKey
607
- try {
608
- sortKey = this._parent._makeSortKey(itemStore)
609
- } catch(e) {
610
- handleError(e)
611
- }
604
+ getLastNode(): Node | undefined {
605
+ // Hack! As explain in the constructor, this getLastNode method actually
606
+ // does not return the last node, but the preceding one.
607
+ return this.getPrecedingNode();
608
+ }
612
609
 
613
- let oldSortStr: string = this._sortStr
614
- let newSortStr: string = sortKey==null ? '' : sortKeyToString(sortKey)
610
+ getActualLastNode(): Node | undefined {
611
+ let child = this.lastChild;
615
612
 
616
- if (oldSortStr!=='' && oldSortStr!==newSortStr) {
617
- this._parent._removeFromPosition(this)
613
+ while(child && child !== this) {
614
+ if (child instanceof Node) return child;
615
+ const node = child.getLastNode();
616
+ if (node) return node;
617
+ child = child.getPrecedingNode();
618
618
  }
619
+ }
619
620
 
620
- this._sortStr = newSortStr
621
- if (newSortStr!=='') {
622
- if (newSortStr !== oldSortStr) {
623
- this._parent._insertAtPosition(this)
624
- }
625
- try {
626
- this._parent._renderer(itemStore)
627
- } catch(e) {
628
- handleError(e)
629
- }
621
+ queueRun() {
622
+ /* c8 ignore next */
623
+ if (currentScope !== ROOT_SCOPE) internalError(4);
624
+
625
+ // We're not calling `remove` here, as we don't want to remove ourselves from
626
+ // the sorted set. `redraw` will take care of that, if needed.
627
+ // Also, we can't use `getLastNode` here, as we've hacked it to return the
628
+ // preceding node instead.
629
+ if (this.sortKey !== undefined) {
630
+ const lastNode = this.getActualLastNode();
631
+ if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
630
632
  }
631
633
 
632
- currentScope = savedScope
634
+ this.delete();
635
+ this.lastChild = this; // apply the hack (see constructor) again
636
+
637
+ topRedrawScope = this;
638
+ this.redraw();
639
+ topRedrawScope = undefined;
633
640
  }
634
- }
635
-
641
+
642
+ redraw() {
643
+ // Have the makeSortKey function return an ordering int/string/array.
636
644
 
637
- /**
638
- * This global is set during the execution of a `Scope.render`. It is used by
639
- * functions like `node`, `text` and `clean`.
640
- */
641
- let currentScope: Scope | undefined
645
+ // Note that we're NOT subscribing on target[itemIndex], as the OnEachScope uses
646
+ // a wildcard subscription to delete/recreate any scopes when that changes.
647
+ // We ARE creating a proxy around the value though (in case its an object/array),
648
+ // so we'll have our own scope subscribe to changes on that.
649
+ const value: DatumType = optProxy((this.parent.target as any)[this.itemIndex]);
642
650
 
643
- /**
644
- * A special Node observer index to subscribe to any value in the map changing.
645
- */
646
- const ANY_INDEX = {}
651
+ // Since makeSortKey may get() the Store, we'll need to set currentScope first.
652
+ let savedScope = currentScope;
653
+ currentScope = this;
654
+
655
+ let sortKey : undefined | string | number;
656
+ try {
657
+ if (this.parent.makeSortKey) {
658
+ let rawSortKey = this.parent.makeSortKey(value, this.itemIndex);
659
+ if (rawSortKey != null) sortKey = rawSortKey instanceof Array ? rawSortKey.map(partToStr).join('') : rawSortKey;
660
+ } else {
661
+ sortKey = this.itemIndex;
662
+ }
663
+ if (typeof sortKey === 'number') sortKey = partToStr(sortKey);
664
+
665
+ if (this.sortKey !== sortKey) {
666
+ // If the sortKey is changed, make sure `this` is removed from the
667
+ // set before setting the new sortKey to it.
668
+ this.parent.sortedSet.remove(this); // Very fast if `this` is not in the set
669
+ this.sortKey = sortKey;
670
+ }
647
671
 
672
+ // We're not adding `this` to the `sortedSet` (yet), as that may not be needed,
673
+ // in case no nodes are created. We'll do it just-in-time in `getPrecedingNode`.
648
674
 
649
- type DatumType = string | number | Function | boolean | null | undefined | ObsMap | ObsArray
675
+ if (sortKey != null) this.parent.renderer(value, this.itemIndex);
676
+ } catch(e) {
677
+ handleError(e, sortKey!=null);
678
+ }
650
679
 
680
+ currentScope = savedScope;
681
+ }
651
682
 
652
- /** @internal */
653
- export abstract class ObsCollection {
654
- _observers: Map<any, Set<Observer>> = new Map()
683
+ getInsertAfterNode() {
684
+ if (this.sortKey == null) internalError(1);
685
+ // Due to the `this` being the first child for `this` hack, this will look
686
+ // for the preceding node as well, if we don't have nodes ourselves.
687
+ return findLastNodeInPrevSiblings(this.lastChild);
688
+ }
655
689
 
656
- // toString(): string {
657
- // return JSON.stringify(peek(() => this.getRecursive(3)))
658
- // }
690
+ remove() {
691
+ // We can't use getLastNode here, as we've hacked it to return the preceding
692
+ // node instead.
693
+ if (this.sortKey !== undefined) {
694
+ const lastNode = this.getActualLastNode();
695
+ if (lastNode) removeNodes(lastNode, this.getPrecedingNode());
659
696
 
660
- _addObserver(index: any, observer: Observer) {
661
- observer = observer
662
- let obsSet = this._observers.get(index)
663
- if (obsSet) {
664
- if (obsSet.has(observer)) return false
665
- obsSet.add(observer)
666
- } else {
667
- this._observers.set(index, new Set([observer]))
668
- }
669
- return true
670
- }
671
-
672
- _removeObserver(index: any, observer: Observer) {
673
- let obsSet = <Set<Observer>>this._observers.get(index)
674
- obsSet.delete(observer)
675
- }
676
-
677
- emitChange(index: any, newData: DatumType, oldData: DatumType) {
678
- let obsSet = this._observers.get(index)
679
- if (obsSet) obsSet.forEach(observer => observer._onChange(index, newData, oldData))
680
- obsSet = this._observers.get(ANY_INDEX)
681
- if (obsSet) obsSet.forEach(observer => observer._onChange(index, newData, oldData))
682
- }
683
-
684
- _clean(observer: Observer) {
685
- this._removeObserver(ANY_INDEX, observer)
686
- }
687
-
688
- _setIndex(index: any, newValue: any, deleteMissing: boolean): void {
689
- const curData = this.rawGet(index)
690
-
691
- if (!(curData instanceof ObsCollection) || newValue instanceof Store || !curData._merge(newValue, deleteMissing)) {
692
- let newData = valueToData(newValue)
693
- if (newData !== curData) {
694
- this.rawSet(index, newData)
695
- this.emitChange(index, newData, curData)
696
- }
697
+ this.parent.sortedSet.remove(this);
698
+ this.sortKey = undefined;
697
699
  }
700
+
701
+ this.delete();
698
702
  }
703
+ }
699
704
 
700
- abstract rawGet(index: any): DatumType
701
- abstract rawSet(index: any, data: DatumType): void
702
- abstract _merge(newValue: any, deleteMissing: boolean): void
703
- abstract _getType(): string
704
- abstract _getRecursive(depth: number): object | Set<any> | Array<any>
705
- abstract _iterateIndexes(scope: OnEachScope): void
706
- abstract _normalizeIndex(index: any): any
707
- abstract _getCount(): number
705
+ function addNode(node: Node) {
706
+ const parentEl = currentScope.parentElement;
707
+ const prevNode = currentScope.getInsertAfterNode();
708
+ parentEl.insertBefore(node, prevNode ? prevNode.nextSibling : parentEl.firstChild);
709
+ currentScope.lastChild = node;
708
710
  }
709
711
 
710
- /** @internal */
711
- class ObsArray extends ObsCollection {
712
- _data: Array<DatumType> = []
713
712
 
714
- _getType() {
715
- return "array"
716
- }
713
+ /**
714
+ * This global is set during the execution of a `Scope.render`. It is used by
715
+ * functions like `$` and `clean`.
716
+ */
717
+ const ROOT_SCOPE = new RootScope();
718
+ let currentScope: ContentScope = ROOT_SCOPE;
717
719
 
718
- _getRecursive(depth: number) {
719
- if (currentScope) {
720
- if (this._addObserver(ANY_INDEX, currentScope)) {
721
- currentScope._cleaners.push(this)
722
- }
723
- }
724
- let result: any[] = []
725
- for(let i=0; i<this._data.length; i++) {
726
- let v = this._data[i]
727
- result.push(v instanceof ObsCollection ? (depth ? v._getRecursive(depth-1) : new Store(this,i)) : v)
728
- }
729
- return result
730
- }
720
+ /**
721
+ * A special Node observer index to subscribe to any value in the map changing.
722
+ */
723
+ const ANY_SYMBOL = Symbol('any');
731
724
 
732
- rawGet(index: any): DatumType {
733
- return this._data[index]
734
- }
725
+ /**
726
+ * When our proxy objects need to lookup `obj[TARGET_SYMBOL]` it returns its
727
+ * target, to be used in our wrapped methods.
728
+ */
729
+ const TARGET_SYMBOL = Symbol('target');
735
730
 
736
- rawSet(index: any, newData: DatumType): void {
737
- if (index !== (0|index) || index<0 || index>999999) {
738
- throw new Error(`Invalid array index ${JSON.stringify(index)}`)
739
- }
740
- this._data[index] = newData
741
- // Remove trailing `undefined`s
742
- while(this._data.length>0 && this._data[this._data.length-1]===undefined) {
743
- this._data.pop()
744
- }
745
- }
746
731
 
747
- _merge(newValue: any, deleteMissing: boolean): boolean {
748
- if (!(newValue instanceof Array)) {
749
- return false
750
- }
751
- // newValue is an array
732
+ const subscribers = new WeakMap<TargetType, Map<any, Set<Scope | ((index: any, newData: DatumType, oldData: DatumType) => void)>>>;
733
+ let peeking = 0; // When > 0, we're not subscribing to any changes
752
734
 
753
- for(let i=0; i<newValue.length; i++) {
754
- this._setIndex(i, newValue[i], deleteMissing)
755
- }
735
+ function subscribe(target: any, index: symbol|string|number, observer: Scope | ((index: any, newData: DatumType, oldData: DatumType) => void) = currentScope) {
736
+ if (observer === ROOT_SCOPE || peeking) return;
756
737
 
757
- // Overwriting just the first elements of an array and leaving the rest of
758
- // the old data in place is just weird and unexpected, so we'll always use
759
- // 'replace' behavior for arrays.
760
- if (/*deleteMissing &&*/ this._data.length > newValue.length) {
761
- for(let i=newValue.length; i<this._data.length; i++) {
762
- let old = this._data[i]
763
- if (old!==undefined) {
764
- this.emitChange(i, undefined, old)
765
- }
766
- }
767
- this._data.length = newValue.length
768
- }
769
- return true
770
- }
738
+ let byTarget = subscribers.get(target);
739
+ if (!byTarget) subscribers.set(target, byTarget = new Map());
771
740
 
741
+ // No need to subscribe to specific keys if we're already subscribed to ANY
742
+ if (index !== ANY_SYMBOL && byTarget.get(ANY_SYMBOL)?.has(observer)) return;
772
743
 
773
- _iterateIndexes(scope: OnEachScope): void {
774
- for(let i=0; i<this._data.length; i++) {
775
- if (this._data[i]!==undefined) {
776
- scope._addChild(i)
777
- }
778
- }
779
- }
744
+ let byIndex = byTarget.get(index);
745
+ if (!byIndex) byTarget.set(index, byIndex = new Set());
780
746
 
781
- _normalizeIndex(index: any): any {
782
- if (typeof index==='number') return index
783
- if (typeof index==='string') {
784
- // Convert to int
785
- let num = 0 | <number><unknown>index
786
- // Check if the number is still the same after conversion
787
- if (index.length && num==<unknown>index) return index
788
- }
789
- throw new Error(`Invalid array index ${JSON.stringify(index)}`)
790
- }
747
+ if (byIndex.has(observer)) return;
791
748
 
792
- _getCount() {
793
- return this._data.length
749
+ byIndex.add(observer);
750
+
751
+ if (observer === currentScope) {
752
+ currentScope.cleaners.push(byIndex);
753
+ } else {
754
+ currentScope.cleaners.push(function() {
755
+ byIndex.delete(observer);
756
+ });
794
757
  }
795
758
  }
796
759
 
797
- /** @internal */
798
- class ObsMap extends ObsCollection {
799
- data: Map<any, DatumType> = new Map()
800
-
801
- _getType() {
802
- return "map"
803
- }
760
+ export function onEach<T>(target: Array<undefined|T>, render: (value: T, index: number) => void, makeKey?: (value: T, key: any) => SortKeyType): void;
761
+ export function onEach<K extends string|number|symbol,T>(target: Record<K,undefined|T>, render: (value: T, index: K) => void, makeKey?: (value: T, key: K) => SortKeyType): void;
804
762
 
805
- _getRecursive(depth: number) {
806
- if (currentScope) {
807
- if (this._addObserver(ANY_INDEX, currentScope)) {
808
- currentScope._cleaners.push(this)
809
- }
810
- }
811
- let result: Map<any,any> = new Map()
812
- this.data.forEach((v: any, k: any) => {
813
- result.set(k, (v instanceof ObsCollection) ? (depth ? v._getRecursive(depth-1) : new Store(this, k)) : v)
814
- })
815
- return result
816
- }
817
-
818
- rawGet(index: any): DatumType {
819
- return this.data.get(index)
820
- }
821
-
822
- rawSet(index: any, newData: DatumType): void {
823
- if (newData===undefined) {
824
- this.data.delete(index)
825
- } else {
826
- this.data.set(index, newData)
827
- }
828
- }
829
-
830
- _merge(newValue: any, deleteMissing: boolean): boolean {
831
- if (!(newValue instanceof Map)) {
832
- return false
833
- }
763
+ /**
764
+ * Reactively iterates over the items of an observable array or object, optionally rendering content for each item.
765
+ *
766
+ * Automatically updates when items are added, removed, or modified.
767
+ *
768
+ * @param target The observable array or object to iterate over. Values that are `undefined` are skipped.
769
+ * @param render A function called for each item in the array. It receives the item's (observable) value and its index/key. 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.
770
+ * @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, items are rendered in array index 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.
771
+ *
772
+ * @example Iterating an array
773
+ * ```typescript
774
+ * const items = proxy(['apple', 'banana', 'cherry']);
775
+ *
776
+ * // Basic iteration
777
+ * onEach(items, (item, index) => $(`li:${item} (#${index})`));
778
+ *
779
+ * // Add a new item - the list updates automatically
780
+ * setTimeout(() => items.push('durian'), 2000);
781
+ * // Same for updates and deletes
782
+ * setTimeout(() => items[1] = 'berry', 4000);
783
+ * setTimeout(() => delete items[2], 6000);
784
+ * ```
785
+ *
786
+ * @example Iterating an array with custom ordering
787
+ * ```typescript
788
+ * const users = proxy([
789
+ * { id: 3, group: 1, name: 'Charlie' },
790
+ * { id: 1, group: 1, name: 'Alice' },
791
+ * { id: 2, group: 2, name: 'Bob' },
792
+ * ]);
793
+ *
794
+ * // Sort by name alphabetically
795
+ * onEach(users, (user) => {
796
+ * $(`p:${user.name} (id=${user.id})`);
797
+ * }, (user) => [user.group, user.name]); // Sort by group, and within each group sort by name
798
+ * ```
799
+ *
800
+ * @example Iterating an object
801
+ * ```javascript
802
+ * const config = proxy({ theme: 'dark', fontSize: 14, showTips: true });
803
+ *
804
+ * // Display configuration options
805
+ * $('dl', () => {
806
+ * onEach(config, (value, key) => {
807
+ * if (key === 'showTips') return; // Don't render this one
808
+ * $('dt:'+key);
809
+ * $('dd:'+value);
810
+ * });
811
+ * });
812
+ *
813
+ * // Change a value - the display updates automatically
814
+ * setTimeout(() => config.fontSize = 16, 2000);
815
+ * ```
816
+ * @see {@link invertString} To easily create keys for reverse sorting.
817
+ */
818
+ export function onEach(target: TargetType, render: (value: DatumType, index: any) => void, makeKey?: (value: DatumType, key: any) => SortKeyType): void {
819
+ if (!target || typeof target !== 'object') throw new Error('onEach requires an object');
820
+ target = (target as any)[TARGET_SYMBOL] || target;
834
821
 
835
- // Walk the pairs of the new value map
836
- newValue.forEach((v: any, k: any) => {
837
- this._setIndex(k, v, deleteMissing)
838
- })
822
+ new OnEachScope(target, render, makeKey);
823
+ }
839
824
 
840
- if (deleteMissing) {
841
- this.data.forEach((v: DatumType, k: any) => {
842
- if (!newValue.has(k)) this._setIndex(k, undefined, false)
843
- })
844
- }
845
- return true
846
- }
825
+ function isObjEmpty(obj: object): boolean {
826
+ for(let k in obj) return false;
827
+ return true;
828
+ }
847
829
 
848
- _iterateIndexes(scope: OnEachScope): void {
849
- this.data.forEach((_, itemIndex) => {
850
- scope._addChild(itemIndex)
851
- })
830
+ /**
831
+ * Reactively checks if an observable array or object is empty.
832
+ *
833
+ * This function not only returns the current emptiness state but also establishes
834
+ * a reactive dependency. If the emptiness state of the `proxied` object or array
835
+ * changes later (e.g., an item is added to an empty array, or the last property
836
+ * is deleted from an object), the scope that called `isEmpty` will be automatically
837
+ * scheduled for re-evaluation.
838
+ *
839
+ * @param proxied The observable array or object (obtained via `observe()`) to check.
840
+ * @returns `true` if the array has length 0 or the object has no own enumerable properties, `false` otherwise.
841
+ *
842
+ * @example
843
+ * ```typescript
844
+ * const items = proxy([]);
845
+ *
846
+ * // Reactively display a message if the items array is empty
847
+ * $('div', () => {
848
+ * if (isEmpty(items)) {
849
+ * $('p', 'i:No items yet!');
850
+ * } else {
851
+ * onEach(items, item=>$('p:'+item));
852
+ * }
853
+ * });
854
+ *
855
+ * // Adding an item will automatically remove the "No items yet!" message
856
+ * setInterval(() => {
857
+ * if (!items.length || Math.random()>0.5) items.push('Item');
858
+ * else items.length = 0;
859
+ * }, 1000)
860
+ * ```
861
+ */
862
+ export function isEmpty(proxied: TargetType): boolean {
863
+ const target = (proxied as any)[TARGET_SYMBOL] || proxied;
864
+ const scope = currentScope;
865
+
866
+ if (target instanceof Array) {
867
+ subscribe(target, 'length', function(index: any, newData: DatumType, oldData: DatumType) {
868
+ if (!newData !== !oldData) queue(scope);
869
+ });
870
+ return !target.length;
871
+ } else {
872
+ const result = isObjEmpty(target);
873
+ subscribe(target, ANY_SYMBOL, function(index: any, newData: DatumType, oldData: DatumType) {
874
+ if (result ? oldData===undefined : newData===undefined) queue(scope);
875
+ });
876
+ return result;
852
877
  }
878
+ }
853
879
 
854
- _normalizeIndex(index: any): any {
855
- return index
856
- }
880
+ /** @private */
881
+ export interface ValueRef<T> {
882
+ value: T;
883
+ }
857
884
 
858
- _getCount() {
859
- return this.data.size
860
- }
861
- }
885
+ /**
886
+ * Reactively counts the number of properties in an objects.
887
+ *
888
+ * @param proxied The observable object to count. In case an `array` is passed in, a {@link ref} to its `.length` will be returned.
889
+ * @returns an observable object for which the `value` property reflects the number of properties in `proxied` with a value other than `undefined`.
890
+ *
891
+ * @example
892
+ * ```typescript
893
+ * const items = proxy({x: 3, y: 7} as any);
894
+ * const cnt = count(items);
895
+ *
896
+ * // Create a DOM text node for the count:
897
+ * $('div', {text: cnt});
898
+ * // <div>2</div>
899
+
900
+ * // Or we can use it in an {@link observe} function:
901
+ * observe(() => console.log("The count is now", cnt.value));
902
+ * // The count is now 2
903
+ *
904
+ * // Adding/removing items will update the count
905
+ * items.z = 12;
906
+ * // Asynchronously, after 0ms:
907
+ * // <div>3</div>
908
+ * // The count is now 3
909
+ * ```
910
+ */
911
+ export function count(proxied: TargetType): ValueRef<number> {
912
+ if (proxied instanceof Array) return ref(proxied, 'length');
862
913
 
863
- /** @internal */
864
- class ObsObject extends ObsMap {
865
- _getType() {
866
- return "object"
867
- }
914
+ const target = (proxied as any)[TARGET_SYMBOL] || proxied;
915
+ let cnt = 0;
916
+ for(let k in target) if (target[k] !== undefined) cnt++;
917
+
918
+ const result = proxy(cnt);
919
+ subscribe(target, ANY_SYMBOL, function(index: any, newData: DatumType, oldData: DatumType) {
920
+ if (oldData===newData) {}
921
+ else if (oldData===undefined) result.value = ++cnt;
922
+ else if (newData===undefined) result.value = --cnt;
923
+ });
924
+
925
+ return result;
926
+ }
868
927
 
869
- _getRecursive(depth: number) {
870
- if (currentScope) {
871
- if (this._addObserver(ANY_INDEX, currentScope)) {
872
- currentScope._cleaners.push(this)
928
+ /** @internal */
929
+ export function defaultEmitHandler(target: TargetType, index: string|symbol|number, newData: DatumType, oldData: DatumType) {
930
+ // We're triggering for values changing from undefined to undefined, as this *may*
931
+ // indicate a change from or to `[empty]` (such as `[,1][0]`).
932
+ if (newData === oldData && newData !== undefined) return;
933
+
934
+ const byTarget = subscribers.get(target);
935
+ if (byTarget===undefined) return;
936
+
937
+ for(const what of [index, ANY_SYMBOL]) {
938
+ let byIndex = byTarget.get(what);
939
+ if (byIndex) {
940
+ for(let observer of byIndex) {
941
+ if (typeof observer === 'function') observer(index, newData, oldData);
942
+ else observer.onChange(index, newData, oldData)
873
943
  }
874
944
  }
875
- let result: any = {}
876
- this.data.forEach((v: any, k: any) => {
877
- result[k] = (v instanceof ObsCollection) ? (depth ? v._getRecursive(depth-1) : new Store(this,k)) : v
878
- })
879
- return result
880
945
  }
881
-
882
- _merge(newValue: any, deleteMissing: boolean): boolean {
883
- if (!newValue || newValue.constructor !== Object) {
884
- return false
946
+ }
947
+ let emit = defaultEmitHandler;
948
+
949
+
950
+ const objectHandler: ProxyHandler<any> = {
951
+ get(target: any, prop: any) {
952
+ if (prop===TARGET_SYMBOL) return target;
953
+ subscribe(target, prop);
954
+ return optProxy(target[prop]);
955
+ },
956
+ set(target: any, prop: any, newData: any) {
957
+ // Make sure newData is unproxied
958
+ if (typeof newData === 'object' && newData) newData = (newData as any)[TARGET_SYMBOL] || newData;
959
+ const oldData = target[prop];
960
+ if (newData !== oldData) {
961
+ target[prop] = newData;
962
+ emit(target, prop, newData, oldData);
963
+ runImmediateQueue();
885
964
  }
965
+ return true;
966
+ },
967
+ deleteProperty(target: any, prop: any) {
968
+ const old = target[prop];
969
+ delete target[prop];
970
+ emit(target, prop, undefined, old);
971
+ runImmediateQueue();
972
+ return true;
973
+ },
974
+ has(target: any, prop: any) {
975
+ const result = prop in target;
976
+ subscribe(target, prop);
977
+ return result;
978
+ },
979
+ ownKeys(target: any) {
980
+ subscribe(target, ANY_SYMBOL);
981
+ return Reflect.ownKeys(target);
982
+ }
983
+ };
984
+
985
+ function arraySet(target: any, prop: any, newData: any) {
986
+ // Make sure newData is unproxied
987
+ if (typeof newData === 'object' && newData) newData = (newData as any)[TARGET_SYMBOL] || newData;
988
+ const oldData = target[prop];
989
+ if (newData !== oldData) {
990
+ let oldLength = target.length;
991
+
992
+ if (prop === 'length') {
993
+ target.length = newData;
886
994
 
887
- // Walk the pairs of the new value object
888
- for(let k in newValue) {
889
- this._setIndex(k, newValue[k], deleteMissing)
890
- }
995
+ // We only need to emit for shrinking, as growing just adds undefineds
996
+ for(let i=newData; i<oldLength; i++) {
997
+ emit(target, i, undefined, target[i]);
998
+ }
999
+ } else {
1000
+ const intProp = parseInt(prop)
1001
+ if (intProp.toString() === prop) prop = intProp;
891
1002
 
892
- if (deleteMissing) {
893
- this.data.forEach((v: DatumType, k: any) => {
894
- if (!newValue.hasOwnProperty(k)) this._setIndex(k, undefined, false)
895
- })
1003
+ target[prop] = newData;
1004
+ emit(target, prop, newData, oldData);
896
1005
  }
897
-
898
- return true
899
- }
900
-
901
- _normalizeIndex(index: any): any {
902
- let type = typeof index
903
- if (type==='string') return index
904
- if (type==='number') return ''+index
905
- throw new Error(`Invalid object index ${JSON.stringify(index)}`)
1006
+ if (target.length !== oldLength) {
1007
+ emit(target, 'length', target.length, oldLength);
1008
+ }
1009
+ runImmediateQueue();
906
1010
  }
1011
+ return true;
1012
+ }
907
1013
 
908
- _getCount() {
909
- let cnt = 0
910
- for(let key of this.data) cnt++
911
- return cnt
912
- }
913
- }
1014
+ const arrayHandler: ProxyHandler<any[]> = {
1015
+ get(target: any, prop: any) {
1016
+ if (prop===TARGET_SYMBOL) return target;
1017
+ let subProp = prop;
1018
+ if (typeof prop !== 'symbol') {
1019
+ const intProp = parseInt(prop);
1020
+ if (intProp.toString() === prop) subProp = intProp;
1021
+ }
1022
+ subscribe(target, subProp);
1023
+ return optProxy(target[prop]);
1024
+ },
1025
+ set: arraySet,
1026
+ deleteProperty(target: any, prop: string|symbol) {
1027
+ return arraySet(target, prop, undefined);
1028
+ },
1029
+ };
1030
+
1031
+ const proxyMap = new WeakMap<TargetType, /*Proxy*/TargetType>();
1032
+
1033
+ function optProxy(value: any): any {
1034
+ // If value is a primitive type or already proxied, just return it
1035
+ if (typeof value !== 'object' || !value || value[TARGET_SYMBOL] !== undefined) {
1036
+ return value;
1037
+ }
1038
+ let proxied = proxyMap.get(value);
1039
+ if (proxied) return proxied // Only one proxy per target!
1040
+
1041
+ proxied = new Proxy(value, value instanceof Array ? arrayHandler : objectHandler);
1042
+ proxyMap.set(value, proxied as TargetType);
1043
+ return proxied;
1044
+ }
914
1045
 
915
1046
 
1047
+ export function proxy<T extends DatumType>(target: Array<T>): Array<T extends number ? number : T extends string ? string : T extends boolean ? boolean : T>;
1048
+ export function proxy<T extends object>(target: T): T;
1049
+ export function proxy<T extends DatumType>(target: T): ValueRef<T extends number ? number : T extends string ? string : T extends boolean ? boolean : T>;
916
1050
 
917
1051
  /**
918
- * A data store that automatically subscribes the current scope to updates
919
- * whenever data is read from it.
1052
+ * Creates a reactive proxy around the given data.
1053
+ *
1054
+ * Reading properties from the returned proxy within a reactive scope (like one created by
1055
+ * {@link $} or {@link observe}) establishes a subscription. Modifying properties *through*
1056
+ * the proxy will notify subscribed scopes, causing them to re-execute.
1057
+ *
1058
+ * - Plain objects and arrays are wrapped in a standard JavaScript `Proxy` that intercepts
1059
+ * property access and mutations, but otherwise works like the underlying data.
1060
+ * - Primitives (string, number, boolean, null, undefined) are wrapped in an object
1061
+ * `{ value: T }` which is then proxied. Access the primitive via the `.value` property.
1062
+ *
1063
+ * Use {@link unproxy} to get the original underlying data back.
1064
+ *
1065
+ * @param target - The object, array, or primitive value to make reactive.
1066
+ * @returns A reactive proxy wrapping the target data.
1067
+ * @template T - The type of the data being proxied.
1068
+ *
1069
+ * @example Object
1070
+ * ```javascript
1071
+ * const state = proxy({ count: 0, message: 'Hello' });
1072
+ * observe(() => console.log(state.message)); // Subscribes to message
1073
+ * setTimeout(() => state.message = 'World', 1000); // Triggers the observe function
1074
+ * setTimeout(() => state.count++, 2000); // Triggers nothing
1075
+ * ```
1076
+ *
1077
+ * @example Array
1078
+ * ```javascript
1079
+ * const items = proxy(['a', 'b']);
1080
+ * observe(() => console.log(items.length)); // Subscribes to length
1081
+ * setTimeout(() => items.push('c'), 2000); // Triggers the observe function
1082
+ * ```
920
1083
  *
921
- * Supported data types are: `string`, `number`, `boolean`, `undefined`, `null`,
922
- * `Array`, `object` and `Map`. The latter three will always have `Store` objects as
923
- * values, creating a tree of `Store`-objects.
1084
+ * @example Primitive
1085
+ * ```javascript
1086
+ * const name = proxy('Aberdeen');
1087
+ * observe(() => console.log(name.value)); // Subscribes to value
1088
+ * setTimeout(() => name.value = 'UI', 2000); // Triggers the observe function
1089
+ * ```
1090
+ *
1091
+ * @example Class instance
1092
+ * ```typescript
1093
+ * class Widget {
1094
+ * constructor(public name: string, public width: number, public height: number) {}
1095
+ * grow() { this.width *= 2; }
1096
+ * toString() { return `${this.name}Widget (${this.width}x${this.height})`; }
1097
+ * }
1098
+ * let graph: Widget = proxy(new Widget('Graph', 200, 100));
1099
+ * observe(() => console.log(''+graph));
1100
+ * setTimeout(() => graph.grow(), 2000);
1101
+ * setTimeout(() => graph.grow(), 4000);
1102
+ * ```
924
1103
  */
1104
+ export function proxy(target: TargetType): TargetType {
1105
+ return optProxy(typeof target === 'object' && target !== null ? target : {value: target});
1106
+ }
925
1107
 
926
- export class Store {
927
- /** @internal */
928
- private _collection: ObsCollection
929
- /** @internal */
930
- private _idx: any
931
-
932
- /**
933
- * Create a new store with the given `value` as its value. Defaults to `undefined` if no value is given.
934
- * When the value is a plain JavaScript object, an `Array` or a `Map`, it will be stored as a tree of
935
- * `Store`s. (Calling {@link Store.get} on the store will recreate the original data strucure, though.)
936
- *
937
- * @example
938
- * ```
939
- * let emptyStore = new Store()
940
- * let numStore = new Store(42)
941
- * let objStore = new Store({x: {alice: 1, bob: 2}, y: [9,7,5,3,1]})
942
- * ```
943
- */
944
- constructor()
945
- constructor(value: any)
946
- /** @internal */
947
- constructor(collection: ObsCollection, index: any)
948
-
949
- constructor(value: any = undefined, index: any = undefined) {
950
- if (index===undefined) {
951
- this._collection = new ObsArray()
952
- this._idx = 0
953
- if (value!==undefined) {
954
- this._collection.rawSet(0, valueToData(value))
955
- }
956
- } else {
957
- if (!(value instanceof ObsCollection)) {
958
- throw new Error("1st parameter should be an ObsCollection if the 2nd is also given")
959
- }
960
- this._collection = value
961
- this._idx = index
962
- }
963
- }
964
-
965
- /**
966
- *
967
- * @returns The index for this Store within its parent collection. This will be a `number`
968
- * when the parent collection is an array, a `string` when it's an object, or any data type
969
- * when it's a `Map`.
970
- *
971
- * @example
972
- * ```
973
- * let store = new Store({x: 123})
974
- * let subStore = store.ref('x')
975
- * assert(subStore.get() === 123)
976
- * assert(subStore.index() === 'x') // <----
977
- * ```
978
- */
979
- index() {
980
- return this._idx
981
- }
982
-
983
- /** @internal */
984
- _clean(scope: Scope) {
985
- this._collection._removeObserver(this._idx, scope)
986
- }
987
-
988
-
989
- /**
990
- * @returns Resolves `path` and then retrieves the value that is there, subscribing
991
- * to all read `Store` values. If `path` does not exist, `undefined` is returned.
992
- * @param path - Any path terms to resolve before retrieving the value.
993
- * @example
994
- * ```
995
- * let store = new Store({a: {b: {c: {d: 42}}}})
996
- * assert('a' in store.get())
997
- * assert(store.get('a', 'b') === {c: {d: 42}})
998
- * ```
999
- */
1000
- get(...path: any[]) : any {
1001
- return this.query({path})
1002
- }
1003
-
1004
- /**
1005
- * Like {@link Store.get}, but doesn't subscribe to changes.
1006
- */
1007
- peek(...path: any[]): any {
1008
- return this.query({path, peek: true})
1009
- }
1108
+ /**
1109
+ * Returns the original, underlying data target from a reactive proxy created by {@link proxy}.
1110
+ * If the input `target` is not a proxy, it is returned directly.
1111
+ *
1112
+ * This is useful when you want to avoid triggering subscriptions during read operations or
1113
+ * re-executes during write operations. Using {@link peek} is an alternative way to achieve this.
1114
+ *
1115
+ * @param target - A proxied object, array, or any other value.
1116
+ * @returns The underlying (unproxied) data, or the input value if it wasn't a proxy.
1117
+ * @template T - The type of the target.
1118
+ *
1119
+ * @example
1120
+ * ```typescript
1121
+ * const userProxy = proxy({ name: 'Frank' });
1122
+ * const rawUser = unproxy(userProxy);
1123
+ *
1124
+ * // Log reactively
1125
+ * $(() => console.log('proxied', userProxy.name));
1126
+ * // The following will only ever log once, as we're not subscribing to any observable
1127
+ * $(() => console.log('unproxied', rawUser.name));
1128
+ *
1129
+ * // This cause the first log to run again:
1130
+ * setTimeout(() => userProxy.name += '!', 1000);
1131
+ *
1132
+ * // This doesn't cause any new logs:
1133
+ * setTimeout(() => rawUser.name += '?', 2000);
1134
+ *
1135
+ * // Both userProxy and rawUser end up as `{name: 'Frank!?'}`
1136
+ * setTimeout(() => console.log('final values', userProxy, rawUser), 3000);
1137
+ * ```
1138
+ */
1139
+ export function unproxy<T>(target: T): T {
1140
+ return target ? (target as any)[TARGET_SYMBOL] || target : target;
1141
+ }
1010
1142
 
1011
- /**
1012
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `number`.
1013
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1014
- */
1015
- getNumber(...path: any[]): number { return <number>this.query({path, type: 'number'}) }
1016
- /**
1017
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `string`.
1018
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1019
- */
1020
- getString(...path: any[]): string { return <string>this.query({path, type: 'string'}) }
1021
- /**
1022
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `boolean`.
1023
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1024
- */
1025
- getBoolean(...path: any[]): boolean { return <boolean>this.query({path, type: 'boolean'}) }
1026
- /**
1027
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `function`.
1028
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1029
- */
1030
- getFunction(...path: any[]): (Function) { return <Function>this.query({path, type: 'function'}) }
1031
- /**
1032
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `array`.
1033
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1034
- */
1035
- getArray(...path: any[]): any[] { return <any[]>this.query({path, type: 'array'}) }
1036
- /**
1037
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `object`.
1038
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1039
- */
1040
- getObject(...path: any[]): object { return <object>this.query({path, type: 'object'}) }
1041
- /**
1042
- * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `map`.
1043
- * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1044
- */
1045
- getMap(...path: any[]): Map<any,any> { return <Map<any,any>>this.query({path, type: 'map'}) }
1143
+ let onDestroyMap: WeakMap<Node, string | Function | true> = new WeakMap();
1046
1144
 
1047
- /**
1048
- * Like {@link Store.get}, but the first parameter is the default value (returned when the Store
1049
- * contains `undefined`). This default value is also used to determine the expected type,
1050
- * and to throw otherwise.
1051
- *
1052
- * @example
1053
- * ```
1054
- * let store = {x: 42}
1055
- * assert(getOr(99, 'x') == 42)
1056
- * assert(getOr(99, 'y') == 99)
1057
- * getOr('hello', x') # throws TypeError (because 42 is not a string)
1058
- * ```
1059
- */
1060
- getOr<T>(defaultValue: T, ...path: any[]): T {
1061
- let type: string = typeof defaultValue
1062
- if (type==='object') {
1063
- if (defaultValue instanceof Map) type = 'map'
1064
- else if (defaultValue instanceof Array) type = 'array'
1065
- }
1066
- return this.query({type, defaultValue, path})
1067
- }
1145
+ function destroyWithClass(element: Element, cls: string) {
1146
+ const classes = cls.split('.').filter(c=>c);
1147
+ element.classList.add(...classes);
1148
+ setTimeout(() => element.remove(), 2000);
1149
+ }
1068
1150
 
1069
- /** Retrieve a value, subscribing to all read `Store` values. This is a more flexible
1070
- * form of the {@link Store.get} and {@link Store.peek} methods.
1071
- *
1072
- * @returns The resulting value, or `undefined` if the `path` does not exist.
1073
- */
1074
- query(opts: {
1075
- /** The value for this path should be retrieved. Defaults to `[]`, meaning the entire `Store`. */
1076
- path?: any[],
1077
- /** A string specifying what type the query is expected to return. Options are:
1078
- * "undefined", "null", "boolean", "number", "string", "function", "array", "map"
1079
- * and "object". If the store holds a different type of value, a `TypeError`
1080
- * exception is thrown. By default (when `type` is `undefined`) no type checking
1081
- * is done.
1082
- */
1083
- type?: string,
1084
- /** Limit the depth of the retrieved data structure to this positive integer.
1085
- * When `depth` is `1`, only a single level of the value at `path` is unpacked. This
1086
- * makes no difference for primitive values (like strings), but for objects, maps and
1087
- * arrays, it means that each *value* in the resulting data structure will be a
1088
- * reference to the `Store` for that value.
1089
- */
1090
- depth?: number,
1091
- /** Return this value when the `path` does not exist. Defaults to `undefined`. */
1092
- defaultValue?: any,
1093
- /** When peek is `undefined` or `false`, the current scope will automatically be
1094
- * subscribed to changes of any parts of the store being read. When `true`, no
1095
- * subscribers will be performed.
1096
- */
1097
- peek?: boolean
1098
- }): any {
1099
- if (opts.peek && currentScope) {
1100
- let savedScope = currentScope
1101
- currentScope = undefined
1102
- let result = this.query(opts)
1103
- currentScope = savedScope
1104
- return result
1105
- }
1106
- let store = opts.path && opts.path.length ? this.ref(...opts.path) : this
1107
- let value = store._observe()
1151
+ /**
1152
+ * Recursively copies properties or array items from `src` to `dst`.
1153
+ * It's designed to work efficiently with reactive proxies created by {@link proxy}.
1154
+ *
1155
+ * - **Minimizes Updates:** When copying between objects/arrays (proxied or not), if a nested object
1156
+ * exists in `dst` with the same constructor as the corresponding object in `src`, `copy`
1157
+ * will recursively copy properties into the existing `dst` object instead of replacing it.
1158
+ * This minimizes change notifications for reactive updates.
1159
+ * - **Handles Proxies:** Can accept proxied or unproxied objects/arrays for both `dst` and `src`.
1160
+ *
1161
+ * @param dst - The destination object/array (proxied or unproxied).
1162
+ * @param src - The source object/array (proxied or unproxied). It won't be modified.
1163
+ * @param flags - Bitmask controlling copy behavior:
1164
+ * - {@link MERGE}: Performs a partial update. Properties in `dst` not present in `src` are kept.
1165
+ * `null`/`undefined` in `src` delete properties in `dst`. Handles partial array updates via object keys.
1166
+ * - {@link SHALLOW}: Performs a shallow copy; when an array/object of the right type doesn't exist in `dst` yet, a reference to the array/object in `src` will be made, instead of creating a copy. If the array/object already exists, it won't be replaced (by a reference), but all items will be individually checked and copied like normal, keeping changes (and therefore UI updates) to a minimum.
1167
+ * @template T - The type of the objects being copied.
1168
+ * @throws Error if attempting to copy an array into a non-array or vice versa (unless {@link MERGE} is set, allowing for sparse array updates).
1169
+ *
1170
+ * @example Basic Copy
1171
+ * ```typescript
1172
+ * const source = proxy({ a: 1, b: { c: 2 } });
1173
+ * const dest = proxy({ b: { d: 3 } });
1174
+ * copy(dest, source);
1175
+ * console.log(dest); // proxy({ a: 1, b: { c: 2 } })
1176
+ * ```
1177
+ *
1178
+ * @example MERGE
1179
+ * ```typescript
1180
+ * const source = { b: { c: 99 }, d: undefined }; // d: undefined will delete
1181
+ * const dest = proxy({ a: 1, b: { x: 5 }, d: 4 });
1182
+ * copy(dest, source, MERGE);
1183
+ * console.log(dest); // proxy({ a: 1, b: { c: 99, x: 5 } })
1184
+ * ```
1185
+ *
1186
+ * @example Partial Array Update with MERGE
1187
+ * ```typescript
1188
+ * const messages = proxy(['msg1', 'msg2', 'msg3']);
1189
+ * const update = { 1: 'updated msg2' }; // Update using object key as index
1190
+ * copy(messages, update, MERGE);
1191
+ * console.log(messages); // proxy(['msg1', 'updated msg2', 'msg3'])
1192
+ * ```
1193
+ *
1194
+ * @example SHALLOW
1195
+ * ```typescript
1196
+ * const source = { nested: [1, 2] };
1197
+ * const dest = {};
1198
+ * copy(dest, source, SHALLOW);
1199
+ * dest.nested.push(3);
1200
+ * console.log(source.nested); // [1, 2, 3] (source was modified)
1201
+ * ```
1202
+ */
1203
+ export function copy<T extends object>(dst: T, src: T, flags: number = 0) {
1204
+ copyRecurse(dst, src, flags);
1205
+ runImmediateQueue();
1206
+ }
1207
+ /** Flag to {@link copy} causing it to use merge semantics. See {@link copy} for details. */
1208
+ export const MERGE = 1;
1209
+ /** Flag to {@link copy} and {@link clone} causing them to create a shallow copy (instead of the deep copy done by default).*/
1210
+ export const SHALLOW = 2;
1211
+ const COPY_SUBSCRIBE = 32;
1212
+ const COPY_EMIT = 64;
1108
1213
 
1109
- if (opts.type && (value!==undefined || opts.defaultValue===undefined)) {
1110
- let type = (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
1111
- if (type !== opts.type) throw new TypeError(`Expecting ${opts.type} but got ${type}`)
1112
- }
1113
- if (value instanceof ObsCollection) {
1114
- return value._getRecursive(opts.depth==null ? -1 : opts.depth-1)
1115
- }
1116
- return value===undefined ? opts.defaultValue : value
1117
- }
1214
+ /**
1215
+ * Clone an (optionally proxied) object or array.
1216
+ *
1217
+ * @param src The object or array to clone. If it is proxied, `clone` will subscribe to any changes to the (nested) data structure.
1218
+ * @param flags
1219
+ * - {@link SHALLOW}: Performs a shallow clone, meaning that only the top-level array or object will be copied, while object/array values will just be references to the original data in `src`.
1220
+ * @template T - The type of the objects being copied.
1221
+ * @returns A new unproxied array or object (of the same type as `src`), containing a deep (by default) copy of `src`.
1222
+ */
1223
+ export function clone<T extends object>(src: T, flags: number = 0): T {
1224
+ const dst = Object.create(Object.getPrototypeOf(src)) as T;
1225
+ copyRecurse(dst, src, flags);
1226
+ return dst;
1227
+ }
1118
1228
 
1119
- /**
1120
- * Checks if the specified collection is empty, and subscribes the current scope to changes of the emptiness of this collection.
1121
- *
1122
- * @param path Any path terms to resolve before retrieving the value.
1123
- * @returns When the specified collection is not empty `true` is returned. If it is empty or if the value is undefined, `false` is returned.
1124
- * @throws When the value is not a collection and not undefined, an Error will be thrown.
1125
- */
1126
- isEmpty(...path: any[]): boolean {
1127
- let store = this.ref(...path)
1128
-
1129
- let value = store._observe()
1130
- if (value instanceof ObsCollection) {
1131
- if (currentScope) {
1132
- let observer = new IsEmptyObserver(currentScope, value, false)
1133
- return !observer.count
1229
+ function copyRecurse(dst: any, src: any, flags: number) {
1230
+ // We never want to subscribe to reads we do to the target (to find changes). So we'll
1231
+ // take the unproxied version and `emit` updates ourselve.
1232
+ let unproxied = dst[TARGET_SYMBOL];
1233
+ if (unproxied) {
1234
+ dst = unproxied;
1235
+ flags |= COPY_EMIT;
1236
+ }
1237
+ // For performance, we'll work on the unproxied `src` and manually subscribe to changes.
1238
+ unproxied = src[TARGET_SYMBOL];
1239
+ if (unproxied) {
1240
+ src = unproxied;
1241
+ // If we're not in peek mode, we'll manually subscribe to all source reads.
1242
+ if (currentScope !== ROOT_SCOPE && !peeking) flags |= COPY_SUBSCRIBE;
1243
+ }
1244
+
1245
+ if (flags&COPY_SUBSCRIBE) subscribe(src, ANY_SYMBOL);
1246
+ if (src instanceof Array) {
1247
+ if (!(dst instanceof Array)) throw new Error("Cannot copy array into object");
1248
+ const dstLen = dst.length;
1249
+ const srcLen = src.length;
1250
+ for(let i=0; i<srcLen; i++) {
1251
+ copyValue(dst, src, i, flags)
1252
+ }
1253
+ // Leaving additional values in the old array doesn't make sense
1254
+ if (srcLen !== dstLen) {
1255
+ if (flags&COPY_EMIT) {
1256
+ for(let i=srcLen; i<dstLen; i++) {
1257
+ const old = dst[i];
1258
+ dst[i] = undefined;
1259
+ emit(dst, i, undefined, old);
1260
+ }
1261
+ dst.length = srcLen;
1262
+ emit(dst, 'length', srcLen, dstLen);
1134
1263
  } else {
1135
- return !value._getCount()
1264
+ dst.length = srcLen;
1136
1265
  }
1137
- } else if (value===undefined) {
1138
- return true
1139
- } else {
1140
- throw new Error(`isEmpty() expects a collection or undefined, but got ${JSON.stringify(value)}`)
1141
1266
  }
1142
- }
1143
-
1144
- /**
1145
- * Returns the number of items in the specified collection, and subscribes the current scope to changes in this count.
1146
- *
1147
- * @param path Any path terms to resolve before retrieving the value.
1148
- * @returns The number of items contained in the collection, or 0 if the value is undefined.
1149
- * @throws When the value is not a collection and not undefined, an Error will be thrown.
1150
- */
1151
- count(...path: any[]): number {
1152
- let store = this.ref(...path)
1153
-
1154
- let value = store._observe()
1155
- if (value instanceof ObsCollection) {
1156
- if (currentScope) {
1157
- let observer = new IsEmptyObserver(currentScope, value, true)
1158
- return observer.count
1159
- } else {
1160
- return value._getCount()
1267
+ } else {
1268
+ for(let k in src) {
1269
+ copyValue(dst, src, k, flags);
1270
+ }
1271
+ if (!(flags & MERGE)) {
1272
+ for(let k in dst) {
1273
+ if (!(k in src)) {
1274
+ const old = dst[k];
1275
+ delete dst[k];
1276
+ if (flags&COPY_EMIT && old !== undefined) {
1277
+ emit(dst, k, undefined, old);
1278
+ }
1279
+ }
1161
1280
  }
1162
- } else if (value===undefined) {
1163
- return 0
1164
- } else {
1165
- throw new Error(`count() expects a collection or undefined, but got ${JSON.stringify(value)}`)
1166
1281
  }
1167
- }
1168
-
1169
- /**
1170
- * Returns a strings describing the type of the store value, subscribing to changes of this type.
1171
- * Note: this currently also subscribes to changes of primitive values, so changing a value from 3 to 4
1172
- * would cause the scope to be rerun. This is not great, and may change in the future. This caveat does
1173
- * not apply to changes made *inside* an object, `Array` or `Map`.
1174
- *
1175
- * @param path Any path terms to resolve before retrieving the value.
1176
- * @returns Possible options: "undefined", "null", "boolean", "number", "string", "function", "array", "map" or "object".
1177
- */
1178
- getType(...path: any[]): string {
1179
- let store = this.ref(...path)
1180
- let value = store._observe()
1181
- return (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
1182
- }
1183
-
1184
- /**
1185
- * Sets the value to the last given argument. Any earlier argument are a Store-path that is first
1186
- * resolved/created using {@link Store.makeRef}.
1187
- *
1188
- * When a `Store` is passed in as the value, its value will be copied (subscribing to changes). In
1189
- * case the value is an object, an `Array` or a `Map`, a *reference* to that data structure will
1190
- * be created, so that changes made through one `Store` will be reflected through the other. Be
1191
- * carefull not to create loops in your `Store` tree that way, as that would cause any future
1192
- * call to {@link Store.get} to throw a `RangeError` (Maximum call stack size exceeded.)
1193
- *
1194
- * If you intent to make a copy instead of a reference, call {@link Store.get} on the origin `Store`.
1195
- *
1196
- *
1197
- * @example
1198
- * ```
1199
- * let store = new Store() // Value is `undefined`
1200
- *
1201
- * store.set('x', 6) // Causes the store to become an object
1202
- * assert(store.get() == {x: 6})
1203
- *
1204
- * store.set('a', 'b', 'c', 'd') // Create parent path as objects
1205
- * assert(store.get() == {x: 6, a: {b: {c: 'd'}}})
1206
- *
1207
- * store.set(42) // Overwrites all of the above
1208
- * assert(store.get() == 42)
1209
- *
1210
- * store.set('x', 6) // Throw Error (42 is not a collection)
1211
- * ```
1212
- */
1213
- set(...pathAndValue: any[]): void {
1214
- let newValue = pathAndValue.pop()
1215
- let store = this.makeRef(...pathAndValue)
1216
- store._collection._setIndex(store._idx, newValue, true)
1217
- runImmediateQueue()
1218
- }
1219
-
1220
- /**
1221
- * Sets the `Store` to the given `mergeValue`, but without deleting any pre-existing
1222
- * items when a collection overwrites a similarly typed collection. This results in
1223
- * a deep merge.
1224
- *
1225
- * @example
1226
- * ```
1227
- * let store = new Store({a: {x: 1}})
1228
- * store.merge({a: {y: 2}, b: 3})
1229
- * assert(store.get() == {a: {x: 1, y: 2}, b: 3})
1230
- * ```
1231
- */
1232
- merge(...pathAndValue: any): void {
1233
- let mergeValue = pathAndValue.pop()
1234
- let store = this.makeRef(...pathAndValue)
1235
- store._collection._setIndex(store._idx, mergeValue, false)
1236
- runImmediateQueue()
1237
- }
1238
-
1239
- /**
1240
- * Sets the value for the store to `undefined`, which causes it to be omitted from the map (or array, if it's at the end)
1241
- *
1242
- * @example
1243
- * ```
1244
- * let store = new Store({a: 1, b: 2})
1245
- * store.delete('a')
1246
- * assert(store.get() == {b: 2})
1247
- *
1248
- * store = new Store(['a','b','c'])
1249
- * store.delete(1)
1250
- * assert(store.get() == ['a', undefined, 'c'])
1251
- * store.delete(2)
1252
- * assert(store.get() == ['a'])
1253
- * ```
1254
- */
1255
- delete(...path: any) {
1256
- let store = this.makeRef(...path)
1257
- store._collection._setIndex(store._idx, undefined, true)
1258
- runImmediateQueue()
1259
- }
1282
+ }
1283
+ }
1260
1284
 
1261
- /**
1262
- * Pushes a value to the end of the Array that is at the specified path in the store.
1263
- * If that store path is `undefined`, an Array is created first.
1264
- * The last argument is the value to be added, any earlier arguments indicate the path.
1265
- *
1266
- * @example
1267
- * ```
1268
- * let store = new Store()
1269
- * store.push(3) // Creates the array
1270
- * store.push(6)
1271
- * assert(store.get() == [3,6])
1272
- *
1273
- * store = new Store({myArray: [1,2]})
1274
- * store.push('myArray', 3)
1275
- * assert(store.get() == {myArray: [1,2,3]})
1276
- * ```
1277
- */
1278
- push(...pathAndValue: any[]): number {
1279
- let newValue = pathAndValue.pop()
1280
- let store = this.makeRef(...pathAndValue)
1281
-
1282
- let obsArray = store._collection.rawGet(store._idx)
1283
- if (obsArray===undefined) {
1284
- obsArray = new ObsArray()
1285
- store._collection._setIndex(store._idx, obsArray, true)
1286
- } else if (!(obsArray instanceof ObsArray)) {
1287
- throw new Error(`push() is only allowed for an array or undefined (which would become an array)`)
1285
+ function copyValue(dst: any, src: any, index: any, flags: number) {
1286
+ let dstValue = dst[index];
1287
+ let srcValue = src[index];
1288
+ if (srcValue !== dstValue) {
1289
+ if (srcValue && dstValue && typeof srcValue === 'object' && typeof dstValue === 'object' && (srcValue.constructor === dstValue.constructor || (flags&MERGE && dstValue instanceof Array))) {
1290
+ copyRecurse(dstValue, srcValue, flags);
1291
+ return;
1288
1292
  }
1289
-
1290
- let newData = valueToData(newValue)
1291
- let pos = obsArray._data.length
1292
- obsArray._data.push(newData)
1293
- obsArray.emitChange(pos, newData, undefined)
1294
- runImmediateQueue()
1295
- return pos
1296
- }
1297
-
1298
- /**
1299
- * {@link Store.peek} the current value, pass it through `func`, and {@link Store.set} the resulting
1300
- * value.
1301
- * @param func The function transforming the value.
1302
- */
1303
- modify(func: (value: any) => any): void {
1304
- this.set(func(this.query({peek: true})))
1305
- }
1306
-
1307
- /**
1308
- * Return a `Store` deeper within the tree by resolving the given `path`,
1309
- * subscribing to every level.
1310
- * In case `undefined` is encountered while resolving the path, a newly
1311
- * created `Store` containing `undefined` is returned. In that case, the
1312
- * `Store`'s {@link Store.isDetached} method will return `true`.
1313
- * In case something other than a collection is encountered, an error is thrown.
1314
- */
1315
- ref(...path: any[]): Store {
1316
- let store: Store = this
1317
-
1318
- for(let i=0; i<path.length; i++) {
1319
- let value = store._observe()
1320
- if (value instanceof ObsCollection) {
1321
- store = new Store(value, value._normalizeIndex(path[i]))
1322
- } else {
1323
- if (value!==undefined) throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`)
1324
- return new DetachedStore()
1325
- }
1293
+
1294
+ if (!(flags&SHALLOW) && srcValue && typeof srcValue === 'object') {
1295
+ // Create an empty object of the same type
1296
+ let copy = Object.create(Object.getPrototypeOf(srcValue));
1297
+ // Copy all properties to it. This doesn't need to emit anything
1298
+ // and MERGE does not apply as this is a new branch.
1299
+ copyRecurse(copy, srcValue, 0);
1300
+ srcValue = copy;
1326
1301
  }
1327
-
1328
- return store
1302
+ const old = dst[index];
1303
+ if (flags&MERGE && srcValue == null) delete dst[index];
1304
+ else dst[index] = srcValue;
1305
+ if (flags&COPY_EMIT) emit(dst, index, srcValue, old)
1329
1306
  }
1307
+ }
1330
1308
 
1331
- /**
1332
- * Similar to `ref()`, but instead of returning `undefined`, new objects are created when
1333
- * a path does not exist yet. An error is still thrown when the path tries to index an invalid
1334
- * type.
1335
- * Unlike `ref`, `makeRef` does *not* subscribe to the path levels, as it is intended to be
1336
- * a write-only operation.
1337
- *
1338
- * @example
1339
- * ```
1340
- * let store = new Store() // Value is `undefined`
1341
- *
1342
- * let ref = store.makeRef('a', 'b', 'c')
1343
- * assert(store.get() == {a: {b: {}}}
1344
- *
1345
- * ref.set(42)
1346
- * assert(store.get() == {a: {b: {c: 42}}}
1347
- *
1348
- * ref.makeRef('d') // Throw Error (42 is not a collection)
1349
- * ```
1350
- */
1351
- makeRef(...path: any[]): Store {
1352
- let store: Store = this
1353
-
1354
- for(let i=0; i<path.length; i++) {
1355
- let value = store._collection.rawGet(store._idx)
1356
- if (!(value instanceof ObsCollection)) {
1357
- if (value!==undefined) throw new Error(`Value ${JSON.stringify(value)} is not a collection (nor undefined) in step ${i} of $(${JSON.stringify(path)})`)
1358
- value = new ObsObject()
1359
- store._collection.rawSet(store._idx, value)
1360
- store._collection.emitChange(store._idx, value, undefined)
1361
- }
1362
- store = new Store(value, value._normalizeIndex(path[i]))
1363
- }
1364
- runImmediateQueue()
1365
- return store
1366
- }
1367
1309
 
1368
- /** @internal */
1369
- _observe() {
1370
- if (currentScope) {
1371
- if (this._collection._addObserver(this._idx, currentScope)) {
1372
- currentScope._cleaners.push(this)
1373
- }
1310
+ interface RefTarget {
1311
+ proxy: TargetType
1312
+ index: any
1313
+ }
1314
+ const refHandler: ProxyHandler<RefTarget> = {
1315
+ get(target: RefTarget, prop: any) {
1316
+ if (prop===TARGET_SYMBOL) {
1317
+ // Create a ref to the unproxied version of the target
1318
+ return ref(unproxy(target.proxy), target.index);
1374
1319
  }
1375
- return this._collection.rawGet(this._idx)
1376
- }
1377
-
1378
- /**
1379
- * Iterate the specified collection (Array, Map or object), running the given code block for each item.
1380
- * When items are added to the collection at some later point, the code block will be ran for them as well.
1381
- * When an item is removed, the {@link Store.clean} handlers left by its code block are executed.
1382
- *
1383
- *
1384
- *
1385
- * @param pathAndFuncs
1386
- */
1387
- onEach(...pathAndFuncs: any): void {
1388
- let makeSortKey = defaultMakeSortKey
1389
- let renderer = pathAndFuncs.pop()
1390
- if (typeof pathAndFuncs[pathAndFuncs.length-1]==='function' && (typeof renderer==='function' || renderer==null)) {
1391
- if (renderer!=null) makeSortKey = renderer
1392
- renderer = pathAndFuncs.pop()
1320
+ if (prop==="value") {
1321
+ return (target.proxy as any)[target.index];
1393
1322
  }
1394
- if (typeof renderer !== 'function') throw new Error(`onEach() expects a render function as its last argument but got ${JSON.stringify(renderer)}`)
1395
-
1396
- if (!currentScope) throw new ScopeError(false)
1397
-
1398
- let store = this.ref(...pathAndFuncs)
1399
-
1400
- let val = store._observe()
1401
- if (val instanceof ObsCollection) {
1402
- // Subscribe to changes using the specialized OnEachScope
1403
- let onEachScope = new OnEachScope(currentScope._parentElement, currentScope._lastChild || currentScope._precedingSibling, currentScope._queueOrder+1, val, renderer, makeSortKey)
1404
- val._addObserver(ANY_INDEX, onEachScope)
1405
-
1406
- currentScope._cleaners.push(onEachScope)
1407
- currentScope._lastChild = onEachScope
1408
-
1409
- onEachScope._renderInitial()
1410
- } else if (val!==undefined) {
1411
- throw new Error(`onEach() attempted on a value that is neither a collection nor undefined`)
1323
+ },
1324
+ set(target: any, prop: any, value: any) {
1325
+ if (prop==="value") {
1326
+ (target.proxy as any)[target.index] = value;
1327
+ return true;
1412
1328
  }
1413
- }
1329
+ return false;
1330
+ },
1331
+ };
1414
1332
 
1415
- /**
1416
- * Applies a filter/map function on each item within the `Store`'s collection,
1417
- * and reactively manages the returned `Map` `Store` to hold any results.
1418
- *
1419
- * @param func - Function that transform the given store into an output value or
1420
- * `undefined` in case this value should be skipped:
1421
- *
1422
- * @returns - A map `Store` with the values returned by `func` and the corresponding
1423
- * keys from the original map, array or object `Store`.
1424
- *
1425
- * When items disappear from the `Store` or are changed in a way that `func` depends
1426
- * upon, the resulting items are removed from the output `Store` as well. When multiple
1427
- * input items produce the same output keys, this may lead to unexpected results.
1428
- */
1429
- map(func: (store: Store) => any): Store {
1430
- let out = new Store(new Map())
1431
- this.onEach((item: Store) => {
1432
- let value = func(item)
1433
- if (value !== undefined) {
1434
- let key = item.index()
1435
- out.set(key, value)
1436
- clean(() => {
1437
- out.delete(key)
1438
- })
1439
- }
1440
- })
1441
- return out
1442
- }
1443
1333
 
1444
- /**
1445
- * Applies a filter/map function on each item within the `Store`'s collection,
1446
- * each of which can deliver any number of key/value pairs, and reactively manages the
1447
- * returned map `Store` to hold any results.
1448
- *
1449
- * @param func - Function that transform the given store into output values
1450
- * that can take one of the following forms:
1451
- * - an `Object` or a `Map`: Each key/value pair will be added to the output `Store`.
1452
- * - anything else: No key/value pairs are added to the output `Store`.
1453
- *
1454
- * @returns - A map `Store` with the key/value pairs returned by all `func` invocations.
1455
- *
1456
- * When items disappear from the `Store` or are changed in a way that `func` depends
1457
- * upon, the resulting items are removed from the output `Store` as well. When multiple
1458
- * input items produce the same output keys, this may lead to unexpected results.
1459
- */
1460
- multiMap(func: (store: Store) => any): Store {
1461
- let out = new Store(new Map())
1462
- this.onEach((item: Store) => {
1463
- let result = func(item)
1464
- let keys: Array<any>
1465
- if (result.constructor === Object) {
1466
- for(let key in result) {
1467
- out.set(key, result[key])
1468
- }
1469
- keys = Object.keys(result)
1470
- } else if (result instanceof Map) {
1471
- result.forEach((value: any, key: any) => {
1472
- out.set(key, value)
1473
- })
1474
- keys = [...result.keys()]
1475
- } else {
1476
- return
1477
- }
1478
- if (keys.length) {
1479
- clean(() => {
1480
- for(let key of keys) {
1481
- out.delete(key)
1482
- }
1483
- })
1484
- }
1485
- })
1486
- return out
1487
- }
1334
+ /**
1335
+ * Creates a reactive reference (`{ value: T }`-like object) to a specific value
1336
+ * within a proxied object or array.
1337
+ *
1338
+ * This is primarily used for the `bind` property in {@link $} to create two-way data bindings
1339
+ * with form elements, and for passing a reactive property to any of the {@link $} key-value pairs.
1340
+ *
1341
+ * Reading `ref.value` accesses the property from the underlying proxy (and subscribes the current scope).
1342
+ * Assigning to `ref.value` updates the property in the underlying proxy (triggering reactive updates).
1343
+ *
1344
+ * @param target - The reactive proxy (created by {@link proxy}) containing the target property.
1345
+ * @param index - The key (for objects) or index (for arrays) of the property to reference.
1346
+ * @returns A reference object with a `value` property linked to the specified proxy property.
1347
+ *
1348
+ * @example
1349
+ * ```javascript
1350
+ * const formData = proxy({ color: 'orange', velocity: 42 });
1351
+ *
1352
+ * // Usage with `bind`
1353
+ * $('input', {
1354
+ * type: 'text',
1355
+ * // Creates a two-way binding between the input's value and formData.username
1356
+ * bind: ref(formData, 'color')
1357
+ * });
1358
+ *
1359
+ * // Usage as a dynamic property, causes a TextNode with just the name to be created and live-updated
1360
+ * $('p:Selected color: ', {
1361
+ * text: ref(formData, 'color'),
1362
+ * $color: ref(formData, 'color')
1363
+ * });
1364
+ *
1365
+ * // Changes are actually stored in formData - this causes logs like `{color: "Blue", velocity 42}`
1366
+ * $(() => console.log(formData))
1367
+ * ```
1368
+ */
1369
+ export function ref<T extends TargetType, K extends keyof T>(target: T, index: K): ValueRef<T[K]> {
1370
+ return new Proxy({proxy: target, index}, refHandler) as any as ValueRef<T[K]>;
1371
+ }
1488
1372
 
1489
- /**
1490
- * @returns Returns `true` when the `Store` was created by {@link Store.ref}ing a path that
1491
- * does not exist.
1492
- */
1493
- isDetached() { return false }
1494
1373
 
1495
- /**
1496
- * Dump a live view of the `Store` tree as HTML text, `ul` and `li` nodes at
1497
- * the current mount position. Meant for debugging purposes.
1498
- */
1499
- dump() {
1500
- let type = this.getType()
1501
- if (type === 'array' || type === 'object' || type === 'map') {
1502
- text('<'+type+'>')
1503
- node('ul', () => {
1504
- this.onEach((sub: Store) => {
1505
- node('li', () => {
1506
- text(JSON.stringify(sub.index())+': ')
1507
- sub.dump()
1508
- })
1509
- })
1510
- })
1511
- }
1512
- else {
1513
- text(JSON.stringify(this.get()))
1514
- }
1374
+ function applyBind(_el: Element, target: any) {
1375
+ const el = _el as HTMLInputElement;
1376
+ let onProxyChange: () => void;
1377
+ let onInputChange: () => void;
1378
+ let type = el.getAttribute('type');
1379
+ let value = unproxy(target).value;
1380
+ if (type === 'checkbox') {
1381
+ if (value === undefined) target.value = el.checked;
1382
+ onProxyChange = () => el.checked = target.value;
1383
+ onInputChange = () => target.value = el.checked;
1384
+ } else if (type === 'radio') {
1385
+ if (value === undefined && el.checked) target.value = el.value;
1386
+ onProxyChange = () => el.checked = (target.value === el.value);
1387
+ onInputChange = () => {
1388
+ if (el.checked) target.value = el.value;
1389
+ }
1390
+ } else {
1391
+ onInputChange = () => target.value = type==='number' || type==='range' ? (el.value==='' ? null : +el.value) : el.value;
1392
+ if (value === undefined) onInputChange();
1393
+ onProxyChange = () => el.value = target.value
1515
1394
  }
1395
+ observe(onProxyChange);
1396
+ el.addEventListener('input', onInputChange);
1397
+ clean(() => {
1398
+ el.removeEventListener('input', onInputChange);
1399
+ });
1516
1400
  }
1517
1401
 
1518
- class DetachedStore extends Store {
1519
- isDetached() { return true }
1402
+ const SPECIAL_PROPS: {[key: string]: (value: any) => void} = {
1403
+ create: function(value: any) {
1404
+ const el = currentScope.parentElement;
1405
+ if (currentScope !== topRedrawScope) return;
1406
+ if (typeof value === 'function') {
1407
+ value(el);
1408
+ } else {
1409
+ const classes = value.split('.').filter((c: any)=>c);
1410
+ el.classList.add(...classes);
1411
+ (async function(){ // attempt to prevent layout trashing
1412
+ (el as HTMLElement).offsetHeight; // trigger layout
1413
+ el.classList.remove(...classes);
1414
+ })();
1415
+ }
1416
+ },
1417
+ destroy: function(value: any) {
1418
+ const el = currentScope.parentElement;
1419
+ onDestroyMap.set(el, value);
1420
+ },
1421
+ html: function(value: any) {
1422
+ let tmpParent = document.createElement(currentScope.parentElement.tagName);
1423
+ tmpParent.innerHTML = ''+value;
1424
+ while(tmpParent.firstChild) addNode(tmpParent.firstChild);
1425
+ },
1426
+ text: function(value: any) {
1427
+ addNode(document.createTextNode(value));
1428
+ },
1429
+ element: function(value: any) {
1430
+ if (!(value instanceof Node)) throw new Error(`Unexpected element-argument: ${JSON.parse(value)}`);
1431
+ addNode(value);
1432
+ },
1520
1433
  }
1521
1434
 
1522
1435
 
1523
1436
 
1524
- let onCreateEnabled = false
1525
- let onDestroyMap: WeakMap<Node, string | Function | true> = new WeakMap()
1526
-
1527
- function destroyWithClass(element: Element, cls: string) {
1528
- element.classList.add(cls)
1529
- setTimeout(() => element.remove(), 2000)
1530
- }
1531
-
1532
-
1533
1437
  /**
1534
- * Create a new DOM element, and insert it into the DOM at the position held by the current scope.
1535
- * @param tag - The tag of the element to be created and optionally dot-separated class names. For example: `h1` or `p.intro.has_avatar`.
1536
- * @param rest - The other arguments are flexible and interpreted based on their types:
1537
- * - `string`: Used as textContent for the element.
1538
- * - `object`: Used as attributes, properties or event listeners for the element. See {@link Store.prop} on how the distinction is made and to read about a couple of special keys.
1539
- * - `function`: The render function used to draw the scope of the element. This function gets its own `Scope`, so that if any `Store` it reads changes, it will redraw by itself.
1540
- * - `Store`: Presuming `tag` is `"input"`, `"textarea"` or `"select"`, create a two-way binding between this `Store` value and the input element. The initial value of the input will be set to the initial value of the `Store`, or the other way around if the `Store` holds `undefined`. After that, the `Store` will be updated when the input changes and vice versa.
1541
- * @example
1542
- * node('aside.editorial', 'Yada yada yada....', () => {
1543
- * node('a', {href: '/bio'}, () => {
1544
- * node('img.author', {src: '/me.jpg', alt: 'The author'})
1545
- * })
1546
- * })
1438
+ * The core function for building reactive user interfaces in Aberdeen. It creates and inserts new DOM elements
1439
+ * and sets attributes/properties/event listeners on DOM elements. It does so in a reactive way, meaning that
1440
+ * changes will be (mostly) undone when the current *scope* is destroyed or will be re-execute.
1441
+ *
1442
+ * @param {...(string | function | object | false | undefined | null)} args - Any number of arguments can be given. How they're interpreted depends on their types:
1443
+ *
1444
+ * - `string`: Strings can be used to create and insert new elements, set classnames for the *current* element, and add text to the current element.
1445
+ * The format of a string is: **tag**? (`.` **class**)* (':' **text**)?
1446
+ * meaning it consists of...
1447
+ * - An optional HTML **tag**, something like `h1`. If present, a DOM element of that tag is created, and that element will be the *current* element for the rest of this `$` function execution.
1448
+ * - Any number of CSS classes prefixed by `.` characters. These classes will be added to the *current* element.
1449
+ * - Optional content **text** prefixed by a `:` character, ranging til the end of the string. This will be added as a TextNode to the *current* element.
1450
+ * - `function`: When a function (without argument nor a return value) is passed in, it will be reactively executed in its own observe scope, preserving the *current element*. So any `$()` invocations within this function will create DOM elements with our *current* element as parent. If the function reads observable data, and that data is changed later on, the function we re-execute (after side effects, such as DOM modifications through `$`, have been cleaned - see also {@link clean}).
1451
+ * - `object`: When an object is passed in, its key-value pairs are used to modify the *current* element in the following ways...
1452
+ * - `{<attrName>: any}`: The common case is setting the value as an HTML attribute named key. So `{placeholder: "Your name"}` would add `placeholder="Your name"` to the current HTML element.
1453
+ * - `{<propName>: boolean}` or `{value: any}` or `{selectedIndex: number}`: If the value is a boolean, or if the key is `value` or `selectedIndex`, it is set on the `current` element as a DOM property instead of an HTML attribute. For example `{checked: true}` would do `el.checked = true` for the *current* element.
1454
+ * - `{".class": boolean}`: If the key starts with a `.` character, its either added to or removed from the *current* element as a CSS class, based on the truthiness of the value. So `{".hidden": hide}` would toggle the `hidden` CSS class.
1455
+ * - `{<eventName>: function}`: If the value is a `function` it is set as an event listener for the event with the name given by the key. For example: `{click: myClickHandler}`.
1456
+ * - `{$<styleProp>: value}`: If the key starts with a `$` character, set a CSS style property with the name of the rest of the key to the given value. Example: `{$backgroundColor: 'red'}`.
1457
+ * - `{create: string}`: Add the value string as a CSS class to the *current* element, *after* the browser has finished doing a layout pass. This behavior only triggers when the scope setting the `create` is the top-level scope being (re-)run. This allows for creation transitions, without triggering the transitions for deeply nested elements being drawn as part of a larger component. The string may also contain multiple dot-separated CSS classes, such as `.fade.grow`.
1458
+ * - `{destroy: string}`: When the *current* element is a top-level element to be removed (due to reactivity cleanup), actual removal from the DOM is delayed by 2 seconds, and in the mean time the value string is added as a CSS class to the element, allowing for a deletion transition. The string may also contain multiple dot-separated CSS classes, such as `.fade.shrink`.
1459
+ * - `{create: function}` and `{destroy: function}`: The function is invoked when the *current* element is the top-level element being created/destroyed. It can be used for more involved creation/deletion animations. In case of `destroy`, the function is responsible for actually removing the element from the DOM (eventually). See `transitions.ts` in the Aberdeen source code for some examples.
1460
+ * - `{bind: <obsValue>}`: Create a two-way binding element between the `value` property of the given observable (proxy) variable, and the *current* input element (`<input>`, `<select>` or `<textarea>`). This is often used together with {@link ref}, in order to use properties other than `.value`.
1461
+ * - `{<any>: <obsvalue>}`: Create a new observe scope and read the `value` property of the given observable (proxy) variable from within it, and apply the contained value using any of the other rules in this list. Example:
1462
+ * ```typescript
1463
+ * const myColor = proxy('red');
1464
+ * $('p:Test', {$color: myColor, click: () => myColor.value = 'yellow'})
1465
+ * // Clicking the text will cause it to change color without recreating the <p> itself
1466
+ * ```
1467
+ * This is often used together with {@link ref}, in order to use properties other than `.value`.
1468
+ * - `{text: string|number}`: Add the value as a `TextNode` to the *current* element.
1469
+ * - `{html: string}`: Add the value as HTML to the *current* element. This should only be used in exceptional situations. And of course, beware of XSS.
1470
+ * - `{element: Node}`: Add a pre-existing HTML `Node` to the *current* element.
1471
+ *
1472
+ *
1473
+ * @example Create Element
1474
+ * ```typescript
1475
+ * $('button.secondary.outline:Submit', {
1476
+ * disabled: true,
1477
+ * click: () => console.log('Clicked!'),
1478
+ * $color: 'red'
1479
+ * });
1480
+ * ```
1481
+ *
1482
+ * @example Nested Elements & Reactive Scope
1483
+ * ```typescript
1484
+ * const state = proxy({ count: 0 });
1485
+ * $('div', () => { // Outer element
1486
+ * // This scope re-renders when state.count changes
1487
+ * $('p:Count is ${state.count}`);
1488
+ * $('button:Increment', { click: () => state.count++ });
1489
+ * });
1490
+ * ```
1491
+ *
1492
+ * @example Two-way Binding
1493
+ * ```typescript
1494
+ * const user = proxy({ name: '' });
1495
+ * $('input', { placeholder: 'Name', bind: ref(user, 'name') });
1496
+ * $('h3', () => { // Reactive scope
1497
+ * $(`:Hello ${user.name || 'stranger'}`);
1498
+ * });
1499
+ * ```
1500
+ *
1501
+ * @example Conditional Rendering
1502
+ * ```typescript
1503
+ * const show = proxy(false);
1504
+ * $('button', { click: () => show.value = !show.value }, () => $(show.value ? ':Hide' : ':Show'));
1505
+ * $(() => { // Reactive scope
1506
+ * if (show.value) {
1507
+ * $('p:Details are visible!');
1508
+ * }
1509
+ * });
1510
+ * ```
1547
1511
  */
1548
- export function node(tag: string|Element = "", ...rest: any[]) {
1549
- if (!currentScope) throw new ScopeError(true)
1550
1512
 
1551
- let el
1552
- if (tag instanceof Element) {
1553
- el = tag
1554
- } else {
1555
- let pos = tag.indexOf('.')
1556
- let classes
1557
- if (pos>=0) {
1558
- classes = tag.substr(pos+1)
1559
- tag = tag.substr(0, pos)
1560
- }
1561
- el = document.createElement(tag || 'div')
1562
- if (classes) {
1563
- // @ts-ignore (replaceAll is polyfilled)
1564
- el.className = classes.replaceAll('.', ' ')
1565
- }
1566
- }
1567
1513
 
1568
- currentScope._addNode(el)
1514
+ export function $(...args: (string | null | undefined | false | (() => void) | Record<string,any>)[]): void {
1515
+ let savedCurrentScope;
1516
+ let err;
1569
1517
 
1570
- for(let item of rest) {
1571
- let type = typeof item
1572
- if (type === 'function') {
1573
- let scope = new SimpleScope(el, undefined, currentScope._queueOrder+1, item)
1574
- if (onCreateEnabled) {
1575
- onCreateEnabled = false
1576
- scope._update()
1577
- onCreateEnabled = true
1518
+ for(let arg of args) {
1519
+ if (arg == null || arg === false) continue;
1520
+ if (typeof arg === 'string') {
1521
+ let text, classes: undefined | string;
1522
+ const textPos = arg.indexOf(':');
1523
+ if (textPos >= 0) {
1524
+ text = arg.substring(textPos+1);
1525
+ arg = arg.substring(0,textPos);
1526
+ }
1527
+ const classPos = arg.indexOf('.');
1528
+ if (classPos >= 0) {
1529
+ classes = arg.substring(classPos+1);
1530
+ arg = arg.substring(0, classPos);
1531
+ }
1532
+ if (arg === '') { // Add text or classes to parent
1533
+ if (text) addNode(document.createTextNode(text));
1534
+ if (classes) {
1535
+ const el = currentScope.parentElement;
1536
+ el.classList.add(...classes.split('.'));
1537
+ if (!savedCurrentScope) {
1538
+ clean(() => el.classList.remove(...classes.split('.')));
1539
+ }
1540
+ }
1541
+ } else if (arg.indexOf(' ') >= 0) {
1542
+ err = `Tag '${arg}' cannot contain space`;
1543
+ break;
1578
1544
  } else {
1579
- scope._update()
1545
+ const el = document.createElement(arg);
1546
+ if (classes) el.className = classes.replaceAll('.', ' ');
1547
+ if (text) el.textContent = text;
1548
+ addNode(el);
1549
+ if (!savedCurrentScope) {
1550
+ savedCurrentScope = currentScope;
1551
+ }
1552
+ let newScope = new ChainedScope(el, true);
1553
+ newScope.lastChild = el.lastChild || undefined;
1554
+ if (topRedrawScope === currentScope) topRedrawScope = newScope;
1555
+ currentScope = newScope;
1580
1556
  }
1581
-
1582
- // Add it to our list of cleaners. Even if `scope` currently has
1583
- // no cleaners, it may get them in a future refresh.
1584
- currentScope._cleaners.push(scope)
1585
- } else if (type === 'string' || type === 'number') {
1586
- el.textContent = item
1587
- } else if (type === 'object' && item && item.constructor === Object) {
1588
- for(let k in item) {
1589
- applyProp(el, k, item[k])
1557
+ }
1558
+ else if (typeof arg === 'object') {
1559
+ if (arg.constructor !== Object) {
1560
+ err = `Unexpected argument: ${arg}`;
1561
+ break;
1562
+ }
1563
+ for(const key in arg) {
1564
+ const val = arg[key];
1565
+ applyArg(key, val);
1590
1566
  }
1591
- } else if (item instanceof Store) {
1592
- bindInput(<HTMLInputElement>el, item)
1593
- } else if (item != null) {
1594
- throw new Error(`Unexpected argument ${JSON.stringify(item)}`)
1567
+ } else if (typeof arg === 'function') {
1568
+ new RegularScope(currentScope.parentElement, arg);
1569
+ } else {
1570
+ err = `Unexpected argument: ${arg}`;
1571
+ break;
1595
1572
  }
1596
1573
  }
1574
+ if (savedCurrentScope) {
1575
+ currentScope = savedCurrentScope;
1576
+ }
1577
+ if (err) throw new Error(err);
1597
1578
  }
1598
1579
 
1599
-
1580
+ let cssCount = 0;
1600
1581
 
1601
1582
  /**
1602
- * Convert an HTML string to one or more DOM elements, and add them to the current DOM scope.
1603
- * @param html - The HTML string. For example `"<section><h2>Test</h2><p>Info..</p></section>"`.
1583
+ * Inserts CSS rules into the document, optionally scoping them with a unique class name.
1584
+ *
1585
+ * Takes a JavaScript object representation of CSS rules. camelCased property keys are
1586
+ * converted to kebab-case (e.g., `fontSize` becomes `font-size`).
1587
+ *
1588
+ * @param style - An object where keys are CSS selectors (or camelCased properties) and values are
1589
+ * CSS properties or nested rule objects.
1590
+ * - Selectors are usually combined as a descendant-relationship (meaning just a space character) with their parent selector.
1591
+ * - In case a selector contains a `&`, that character will be replaced by the parent selector.
1592
+ * - Selectors will be split on `,` characters, each combining with the parent selector with *or* semantics.
1593
+ * - Selector starting with `'@'` define at-rules like media queries. They may be nested within regular selectors.
1594
+ * @param global - If `true`, styles are inserted globally without prefixing.
1595
+ * If `false` (default), all selectors are prefixed with a unique generated
1596
+ * class name (e.g., `.AbdStl1`) to scope the styles.
1597
+ * @returns The unique class name prefix used for scoping (e.g., `.AbdStl1`), or an empty string
1598
+ * if `global` was `true`. Use this prefix with {@link $} to apply the styles.
1599
+ *
1600
+ * @example Scoped Styles
1601
+ * ```typescript
1602
+ * const scopeClass = insertCss({
1603
+ * color: 'red',
1604
+ * padding: '10px',
1605
+ * '&:hover': { // Use '&' for the root scoped selector
1606
+ * backgroundColor: '#535'
1607
+ * },
1608
+ * '.child-element': { // Nested selector
1609
+ * fontWeight: 'bold'
1610
+ * },
1611
+ * '@media (max-width: 600px)': {
1612
+ * padding: '5px'
1613
+ * }
1614
+ * });
1615
+ * // scopeClass might be ".AbdStl1"
1616
+ *
1617
+ * // Apply the styles
1618
+ * $(scopeClass, () => { // Add class to the div
1619
+ * $(`:Scoped content`);
1620
+ * $('div.child-element:Child'); // .AbdStl1 .child-element rule applies
1621
+ * });
1622
+ * ```
1623
+ *
1624
+ * @example Global Styles
1625
+ * ```typescript
1626
+ * insertCss({
1627
+ * '*': {
1628
+ * fontFamily: 'monospace',
1629
+ * },
1630
+ * 'a': {
1631
+ * textDecoration: 'none',
1632
+ * color: "#107ab0",
1633
+ * }
1634
+ * }, true); // Pass true for global
1635
+ *
1636
+ * $('a:Styled link');
1637
+ * ```
1604
1638
  */
1605
- export function html(html: string) {
1606
- if (!currentScope || !currentScope._parentElement) throw new ScopeError(true)
1607
- let tmpParent = document.createElement(currentScope._parentElement.tagName)
1608
- tmpParent.innerHTML = ''+html
1609
- while(tmpParent.firstChild) {
1610
- currentScope._addNode(tmpParent.firstChild)
1611
- }
1639
+ export function insertCss(style: object, global: boolean = false): string {
1640
+ const prefix = global ? "" : ".AbdStl" + ++cssCount;
1641
+ let css = styleToCss(style, prefix);
1642
+ if (css) $('style:'+css);
1643
+ return prefix;
1612
1644
  }
1613
1645
 
1614
- function bindInput(el: HTMLInputElement, store: Store) {
1615
- let onStoreChange: (value: any) => void
1616
- let onInputChange: () => void
1617
- let type = el.getAttribute('type')
1618
- let value = store.query({peek: true})
1619
- if (type === 'checkbox') {
1620
- if (value === undefined) store.set(el.checked)
1621
- onStoreChange = value => el.checked = value
1622
- onInputChange = () => store.set(el.checked)
1623
- } else if (type === 'radio') {
1624
- if (value === undefined && el.checked) store.set(el.value)
1625
- onStoreChange = value => el.checked = (value === el.value)
1626
- onInputChange = () => {
1627
- if (el.checked) store.set(el.value)
1628
- }
1629
- } else {
1630
- onInputChange = () => store.set(type==='number' || type==='range' ? (el.value==='' ? null : +el.value) : el.value)
1631
- if (value === undefined) onInputChange()
1632
- onStoreChange = value => {
1633
- if (el.value !== value) el.value = value
1646
+ function styleToCss(style: object, prefix: string): string {
1647
+ let props = '';
1648
+ let rules = '';
1649
+ for(const kOr in style) {
1650
+ const v = (style as any)[kOr];
1651
+ for(const k of kOr.split(/, ?/g)) {
1652
+ if (v && typeof v === 'object') {
1653
+ if (k.startsWith('@')) { // media queries
1654
+ rules += k + '{\n' + styleToCss(v, prefix) + '}\n';
1655
+ } else {
1656
+ rules += styleToCss(v, k.includes('&') ? k.replace(/&/g, prefix) : prefix+' '+k);
1657
+ }
1658
+ } else {
1659
+ props += k.replace(/[A-Z]/g, letter => '-'+letter.toLowerCase()) +":"+v+";";
1660
+ }
1634
1661
  }
1635
1662
  }
1636
- observe(() => {
1637
- onStoreChange(store.get())
1638
- })
1639
- el.addEventListener('input', onInputChange)
1640
- clean(() => {
1641
- el.removeEventListener('input', onInputChange)
1642
- })
1643
-
1663
+ if (props) rules = (prefix.trimStart() || '*') + '{'+props+'}\n' + rules;
1664
+ return rules;
1644
1665
  }
1645
1666
 
1646
- /**
1647
- * Add a text node at the current Scope position.
1648
- */
1649
- export function text(text: string) {
1650
- if (!currentScope) throw new ScopeError(true)
1651
- if (text==null) return
1652
- currentScope._addNode(document.createTextNode(text))
1667
+ function applyArg(key: string, value: any) {
1668
+ const el = currentScope.parentElement;
1669
+ if (typeof value === 'object' && value !== null && value[TARGET_SYMBOL]) { // Value is a proxy
1670
+ if (key === 'bind') {
1671
+ applyBind(el, value)
1672
+ } else {
1673
+ new SetArgScope(el, key, value)
1674
+ // SetArgScope will (repeatedly) call `applyArg` again with the actual value
1675
+ }
1676
+ } else if (key[0] === '.') { // CSS class(es)
1677
+ const classes = key.substring(1).split('.');
1678
+ if (value) el.classList.add(...classes);
1679
+ else el.classList.remove(...classes);
1680
+ } else if (key[0] === '$') { // Style
1681
+ const name = key.substring(1);
1682
+ if (value==null || value===false) (el as any).style[name] = ''
1683
+ else (el as any).style[name] = ''+value;
1684
+ } else if (value == null) { // Value left empty
1685
+ // Do nothing
1686
+ } else if (key in SPECIAL_PROPS) { // Special property
1687
+ SPECIAL_PROPS[key](value);
1688
+ } else if (typeof value === 'function') { // Event listener
1689
+ el.addEventListener(key, value);
1690
+ clean(() => el.removeEventListener(key, value));
1691
+ } else if (value===true || value===false || key==='value' || key==='selectedIndex') { // DOM property
1692
+ (el as any)[key] = value;
1693
+ } else { // HTML attribute
1694
+ el.setAttribute(key, value);
1695
+ }
1653
1696
  }
1654
1697
 
1698
+ function defaultOnError(error: Error) {
1699
+ console.error('Error while in Aberdeen render:', error);
1700
+ return true;
1701
+ }
1702
+ let onError: (error: Error) => boolean | undefined = defaultOnError;
1655
1703
 
1656
1704
  /**
1657
- * Set properties and attributes for the containing DOM element. Doing it this way
1658
- * as opposed to setting them directly from node() allows changing them later on
1659
- * without recreating the element itself. Also, code can be more readable this way.
1660
- * Note that when a nested `observe()` is used, properties set this way do NOT
1661
- * automatically revert to their previous values.
1662
- *
1663
- * Here's how properties are handled:
1664
- * - If `name` is `"create"`, `value` should be either a function that gets
1665
- * called with the element as its only argument immediately after creation,
1666
- * or a string being the name of a CSS class that gets added immediately
1667
- * after element creation, and removed shortly afterwards. This allows for
1668
- * reveal animations. However, this is intentionally *not* done
1669
- * for elements that are created as part of a larger (re)draw, to prevent
1670
- * all elements from individually animating on page creation.
1671
- * - If `name` is `"destroy"`, `value` should be a function that gets called
1672
- * with the element as its only argument, *instead of* the element being
1673
- * removed from the DOM (which the function will presumably need to do
1674
- * eventually). This can be used for a conceal animation.
1675
- * As a convenience, it's also possible to provide a string instead of
1676
- * a function, which will be added to the element as a CSS class, allowing
1677
- * for simple transitions. In this case, the DOM element in removed 2 seconds
1678
- * later (currently not configurable).
1679
- * Similar to `"create"` (and in this case doing anything else would make little
1680
- * sense), this only happens when the element being is the top-level element
1681
- * being removed from the DOM.
1682
- * - If `value` is a function, it is registered as an event handler for the
1683
- * `name` event.
1684
- * - If `name` is `"class"` or `"className"` and the `value` is an
1685
- * object, all keys of the object are either added or removed from `classList`,
1686
- * depending on whether `value` is true-like or false-like.
1687
- * - If `value` is a boolean *or* `name` is `"value"`, `"className"` or
1688
- * `"selectedIndex"`, it is set as a DOM element *property*.
1689
- * - If `name` is `"text"`, the `value` is set as the element's `textContent`.
1690
- * - If `name` is `"style"` and `value` is an object, each of its
1691
- * key/value pairs are assigned to the element's `.style`.
1692
- * - In other cases, the `value` is set as the `name` HTML *attribute*.
1705
+ * Sets a custom error handler function for errors that occur asynchronously
1706
+ * within reactive scopes (e.g., during updates triggered by proxy changes in
1707
+ * {@link observe} or {@link $} render functions).
1693
1708
  *
1694
- * @example
1695
- * ```
1696
- * node('input', () => {
1697
- * prop('type', 'password')
1698
- * prop('readOnly', true)
1699
- * prop('class', 'my-class')
1700
- * prop('class', {
1701
- * 'my-disabled-class': false,
1702
- * 'my-enabled-class': true,
1703
- * })
1704
- * prop({
1705
- * class: 'my-class',
1706
- * text: 'Here is something to read...',
1707
- * style: {
1708
- * backgroundColor: 'red',
1709
- * fontWeight: 'bold',
1710
- * },
1711
- * create: aberdeen.fadeIn,
1712
- * destroy: 'my-fade-out-class',
1713
- * click: myClickHandler,
1714
- * })
1709
+ * The default handler logs the error to `console.error` and adds a simple
1710
+ * 'Error' message div to the DOM at the location where the error occurred (if possible).
1711
+ *
1712
+ * Your handler can provide custom logging, UI feedback, or suppress the default
1713
+ * error message.
1714
+ *
1715
+ * @param handler - A function that accepts the `Error` object.
1716
+ * - Return `false` to prevent adding an error message to the DOM.
1717
+ * - Return `true` or `undefined` (or throw) to allow the error messages to be added to the DOM.
1718
+ *
1719
+ * @example Custom Logging and Suppressing Default Message
1720
+ * ```typescript
1721
+ * setErrorHandler(error => {
1722
+ * console.warn('Aberdeen render error:', error.message);
1723
+ * // Log to error reporting service
1724
+ * // myErrorReporter.log(error);
1725
+ *
1726
+ * try {
1727
+ * // Attempt to show a custom message in the UI
1728
+ * $('div.error-display:Oops, something went wrong!');
1729
+ * } catch (e) {
1730
+ * // Ignore errors during error handling itself
1731
+ * }
1732
+ *
1733
+ * return false; // Suppress default console log and DOM error message
1734
+ * });
1735
+ *
1736
+ * // Cause an error within a render scope.
1737
+ * $('div.box', () => {
1738
+ * noSuchFunction();
1715
1739
  * })
1716
1740
  * ```
1717
1741
  */
1718
- export function prop(name: string, value: any): void
1719
- export function prop(props: object): void
1720
-
1721
- export function prop(name: any, value: any = undefined) {
1722
- if (!currentScope || !currentScope._parentElement) throw new ScopeError(true)
1723
- if (typeof name === 'object') {
1724
- for(let k in name) {
1725
- applyProp(currentScope._parentElement, k, name[k])
1726
- }
1727
- } else {
1728
- applyProp(currentScope._parentElement, name, value)
1729
- }
1742
+ export function setErrorHandler(handler?: (error: Error) => boolean | undefined) {
1743
+ onError = handler || defaultOnError;
1730
1744
  }
1731
1745
 
1732
1746
 
1733
1747
  /**
1734
- * Return the browser Element that `node()`s would be rendered to at this point.
1735
- * NOTE: Manually changing the DOM is not recommended in most cases. There is
1736
- * usually a better, declarative way. Although there are no hard guarantees on
1737
- * how your changes interact with Aberdeen, in most cases results will not be
1738
- * terribly surprising. Be careful within the parent element of onEach() though.
1748
+ * Gets the parent DOM `Element` where nodes created by {@link $} would currently be inserted.
1749
+ *
1750
+ * This is context-dependent based on the current reactive scope (e.g., inside a {@link mount}
1751
+ * call or a {@link $} element's render function).
1752
+ *
1753
+ * **Note:** While this provides access to the DOM element, directly manipulating it outside
1754
+ * of Aberdeen's control is generally discouraged. Prefer declarative updates using {@link $}.
1755
+ *
1756
+ * @returns The current parent `Element` for DOM insertion.
1757
+ *
1758
+ * @example Get parent for attaching a third-party library
1759
+ * ```typescript
1760
+ * function thirdPartyLibInit(parentElement) {
1761
+ * parentElement.innerHTML = "This element is managed by a <em>third party</em> lib."
1762
+ * }
1763
+ *
1764
+ * $('div.box', () => {
1765
+ * // Get the div.box element just created
1766
+ * const containerElement = getParentElement();
1767
+ * thirdPartyLibInit(containerElement);
1768
+ * });
1769
+ * ```
1739
1770
  */
1740
1771
  export function getParentElement(): Element {
1741
- if (!currentScope || !currentScope._parentElement) throw new ScopeError(true)
1742
- return currentScope._parentElement
1772
+ return currentScope.parentElement;
1743
1773
  }
1744
1774
 
1745
1775
 
1746
1776
  /**
1747
- * Register a function that is to be executed right before the current reactive scope
1748
- * disappears or redraws.
1749
- * @param clean - The function to be executed.
1777
+ * Registers a cleanup function to be executed just before the current reactive scope
1778
+ * is destroyed or redraws.
1779
+ *
1780
+ * This is useful for releasing resources, removing manual event listeners, or cleaning up
1781
+ * side effects associated with the scope. Cleaners are run in reverse order of registration.
1782
+ *
1783
+ * Scopes are created by functions like {@link observe}, {@link mount}, {@link $} (when given a render function),
1784
+ * and internally by constructs like {@link onEach}.
1785
+ *
1786
+ * @param cleaner - The function to execute during cleanup.
1787
+ *
1788
+ * @example Maintaing a sum for a changing array
1789
+ * ```typescript
1790
+ * const myArray = proxy([3, 5, 10]);
1791
+ * let sum = proxy(0);
1792
+ *
1793
+ * // Show the array items and maintain the sum
1794
+ * onEach(myArray, (item, index) => {
1795
+ * $(`code:${index}→${item}`);
1796
+ * // We'll update sum.value using peek, as += first does a read, but
1797
+ * // we don't want to subscribe.
1798
+ * peek(() => sum.value += item);
1799
+ * // Clean gets called before each rerun for a certain item index
1800
+ * // No need for peek here, as the clean code doesn't run in an
1801
+ * // observe scope.
1802
+ * clean(() => sum.value -= item);
1803
+ * })
1804
+ *
1805
+ * // Show the sum
1806
+ * $('h1', {text: sum});
1807
+ *
1808
+ * // Make random changes to the array
1809
+ * const rnd = () => 0|(Math.random()*20);
1810
+ * setInterval(() => myArray[rnd()] = rnd(), 1000);
1811
+ * ```
1750
1812
  */
1751
- export function clean(clean: () => void) {
1752
- if (!currentScope) throw new ScopeError(false)
1753
- currentScope._cleaners.push({_clean: clean})
1813
+
1814
+ export function clean(cleaner: () => void) {
1815
+ currentScope.cleaners.push(cleaner);
1754
1816
  }
1755
1817
 
1756
1818
 
1757
1819
  /**
1758
- * Reactively run a function, meaning the function will rerun when any `Store` that was read
1759
- * during its execution is updated.
1760
- * Calls to `observe` can be nested, such that changes to `Store`s read by the inner function do
1761
- * no cause the outer function to rerun.
1820
+ * Creates a reactive scope that automatically re-executes the provided function
1821
+ * whenever any proxied data (created by {@link proxy}) read during its last execution changes, storing
1822
+ * its return value in an observable.
1762
1823
  *
1763
- * @param func - The function to be (repeatedly) executed.
1764
- * @returns The mount id (usable for `unmount`) if this is a top-level observe.
1765
- * @example
1824
+ * Updates are batched and run asynchronously shortly after the changes occur.
1825
+ * Use {@link clean} to register cleanup logic for the scope.
1826
+ * Use {@link peek} or {@link unproxy} within the function to read proxied data without subscribing to it.
1827
+ *
1828
+ * @param func - The function to execute reactively. Any DOM manipulations should typically
1829
+ * be done using {@link $} within this function. Its return value will be made available as an
1830
+ * observable returned by the `observe()` function.
1831
+ * @returns An observable object, with its `value` property containing whatever the last run of `func` returned.
1832
+ *
1833
+ * @example Observation creating a UI components
1834
+ * ```typescript
1835
+ * const data = proxy({ user: 'Frank', notifications: 42 });
1836
+ *
1837
+ * $('main', () => {
1838
+ * console.log('Welcome');
1839
+ * $('h3:Welcome, ' + data.user); // Reactive text
1840
+ *
1841
+ * observe(() => {
1842
+ * // When data.notifications changes, only this inner scope reruns,
1843
+ * // leaving the `<p>Welcome, ..</p>` untouched.
1844
+ * console.log('Notifications');
1845
+ * $('code.notification-badge:' + data.notifications);
1846
+ * $('a:Notify!', {click: () => data.notifications++});
1847
+ * });
1848
+ * });
1766
1849
  * ```
1767
- * let number = new Store(0)
1768
- * let doubled = new Store()
1769
- * setInterval(() => number.set(0|Math.random()*100)), 1000)
1850
+ *
1851
+ * ***Note*** that the above could just as easily be done using `$(func)` instead of `observe(func)`.
1852
+ *
1853
+ * @example Observation with return value
1854
+ * ```typescript
1855
+ * const counter = proxy(0);
1856
+ * setInterval(() => counter.value++, 1000);
1857
+ * const double = observe(() => counter.value * 2);
1770
1858
  *
1771
- * observe(() => {
1772
- * doubled.set(number.get() * 2)
1859
+ * $('h3', () => {
1860
+ * $(`:counter=${counter.value} double=${double.value}`);
1773
1861
  * })
1862
+ * ```
1774
1863
  *
1775
- * observe(() => {
1776
- * console.log(doubled.get())
1777
- * })
1864
+ * @overload
1865
+ * @param func Func without a return value.
1778
1866
  */
1779
- export function observe(func: () => void): number | undefined {
1780
- return _mount(undefined, func, SimpleScope)
1867
+ export function observe<T extends (DatumType | void)>(func: () => T): ValueRef<T> {
1868
+ return (new ResultScope<T>(currentScope.parentElement, func)).result;
1781
1869
  }
1782
1870
 
1783
1871
  /**
1784
- * Like `observe`, but instead of deferring running the observer function until
1785
- * a setTimeout 0, run it immediately and synchronously when a change to one of
1786
- * the observed `Store`s is made. Use this sparingly, as this prevents Aberdeen
1787
- * from doing the usual batching and smart ordering of observers, leading to
1788
- * performance problems and observing of 'weird' partial states.
1789
- * @param func The function to be (repeatedly) executed.
1790
- * @returns The mount id (usable for `unmount`) if this is a top-level observe.
1872
+ * Similar to {@link observe}, creates a reactive scope that re-executes the function
1873
+ * when its proxied dependencies change.
1874
+ *
1875
+ * **Difference:** Updates run **synchronously and immediately** after the proxy modification
1876
+ * that triggered the update occurs.
1877
+ *
1878
+ * **Caution:** Use sparingly. Immediate execution bypasses Aberdeen's usual batching and
1879
+ * ordering optimizations, which can lead to performance issues or observing inconsistent
1880
+ * intermediate states if multiple related updates are applied sequentially.
1881
+ * Prefer {@link observe} or {@link $} for most use cases.
1882
+ *
1883
+ * @param func - The function to execute reactively and synchronously.
1884
+ *
1885
+ * @example
1886
+ * ```javascript
1887
+ * const state = proxy({ single: 'A' });
1888
+ *
1889
+ * immediateObserve(() => {
1890
+ * state.double = state.single + state.single
1891
+ * });
1892
+ * console.log(state.double); // 'AA'
1893
+ *
1894
+ * state.single = 'B';
1895
+ * // Synchronously:
1896
+ * console.log(state.double); // 'BB'
1897
+ * ```
1791
1898
  */
1792
- export function immediateObserve(func: () => void): number | undefined {
1793
- return _mount(undefined, func, ImmediateScope)
1899
+ export function immediateObserve(func: () => void) {
1900
+ new ImmediateScope(currentScope.parentElement, func);
1794
1901
  }
1795
1902
 
1796
-
1797
1903
  /**
1798
- * Like {@link Store.observe}, but allow the function to create DOM elements using {@link Store.node}.
1799
-
1800
- * @param func - The function to be (repeatedly) executed, possibly adding DOM elements to `parentElement`.
1801
- * @param parentElement - A DOM element that will be used as the parent element for calls to `node`.
1802
- * @returns The mount id (usable for `unmount`) if this is a top-level mount.
1904
+ * Attaches a reactive Aberdeen UI fragment to an existing DOM element. Without the use of
1905
+ * this function, {@link $} will assume `document.body` as its root.
1803
1906
  *
1804
- * @example
1805
- * ```
1806
- * let store = new Store(0)
1807
- * setInterval(() => store.modify(v => v+1), 1000)
1907
+ * It creates a top-level reactive scope associated with the `parentElement`. The provided
1908
+ * function `func` is executed immediately within this scope. Any proxied data read by `func`
1909
+ * will cause it to re-execute when the data changes, updating the DOM elements created within it.
1808
1910
  *
1809
- * mount(document.body, () => {
1810
- * node('h2', `${store.get()} seconds have passed`)
1811
- * })
1812
- * ```
1911
+ * Calls to {@link $} inside `func` will append nodes to `parentElement`.
1912
+ * You can nest {@link observe} or other {@link $} scopes within `func`.
1913
+ * Use {@link unmountAll} to clean up all mounted scopes and their DOM nodes.
1914
+ *
1915
+ * Mounting scopes happens reactively, meaning that if this function is called from within another
1916
+ * ({@link observe} or {@link $} or {@link mount}) scope that gets cleaned up, so will the mount.
1813
1917
  *
1814
- * An example nesting {@link Store.observe} within `mount`:
1815
- * ```
1816
- * let selected = new Store(0)
1817
- * let colors = new Store(new Map())
1818
- *
1819
- * mount(document.body, () => {
1820
- * // This function will never rerun (as it does not read any `Store`s)
1821
- * node('button', '<<', {click: () => selected.modify(n => n-1)})
1822
- * node('button', '>>', {click: () => selected.modify(n => n+1)})
1823
- *
1824
- * observe(() => {
1825
- * // This will rerun whenever `selected` changes, recreating the <h2> and <input>.
1826
- * node('h2', '#'+selected.get())
1827
- * node('input', {type: 'color', value: '#ffffff'}, colors.ref(selected.get()))
1828
- * })
1829
- *
1830
- * observe(() => {
1831
- * // This function will rerun when `selected` or the selected color changes.
1832
- * // It will change the <body> background-color.
1833
- * prop({style: {backgroundColor: colors.get(selected.get()) || 'white'}})
1834
- * })
1835
- * })
1918
+ * @param parentElement - The native DOM `Element` to which the UI fragment will be appended.
1919
+ * @param func - The function that defines the UI fragment, typically containing calls to {@link $}.
1920
+ *
1921
+ * @example Basic Mount
1922
+ * ```javascript
1923
+ * // Create a pre-existing DOM structure (without Aberdeen)
1924
+ * document.body.innerHTML = `<h3>Static content <span id="title-extra"></span></h3><div class="box" id="app-root"></div>`;
1925
+ *
1926
+ * import { mount, $, proxy } from 'aberdeen';
1927
+ *
1928
+ * const runTime = proxy(0);
1929
+ * setInterval(() => runTime.value++, 1000);
1930
+ *
1931
+ * mount(document.getElementById('app-root'), () => {
1932
+ * $('h4:Aberdeen App');
1933
+ * $(`p:Run time: ${runTime.value}s`);
1934
+ * // Conditionally render some content somewhere else in the static page
1935
+ * if (runTime.value&1) {
1936
+ * mount(document.getElementById('title-extra'), () =>
1937
+ * $(`i:(${runTime.value}s)`)
1938
+ * );
1939
+ * }
1940
+ * });
1836
1941
  * ```
1837
- */
1838
- export function mount(parentElement: Element, func: () => void) {
1839
- return _mount(parentElement, func, SimpleScope)
1840
- }
1841
-
1842
- let maxTopScopeId = 0
1843
- const topScopes: Map<number, SimpleScope> = new Map()
1844
- function _mount(parentElement: Element | undefined, func: () => void, MountScope: typeof SimpleScope): number | undefined {
1845
- let scope
1846
- if (parentElement || !currentScope) {
1847
- scope = new MountScope(parentElement, undefined, 0, func)
1848
- } else {
1849
- scope = new MountScope(currentScope._parentElement, currentScope._lastChild || currentScope._precedingSibling, currentScope._queueOrder+1, func)
1850
- currentScope._lastChild = scope
1851
- }
1852
-
1853
- // Do the initial run
1854
- scope._update()
1942
+ *
1943
+ * Note how the inner mount behaves reactively as well, automatically unmounting when it's parent observer scope re-runs.
1944
+ */
1855
1945
 
1856
- // Add it to our list of cleaners. Even if `scope` currently has
1857
- // no cleaners, it may get them in a future refresh.
1858
- if (currentScope) {
1859
- currentScope._cleaners.push(scope)
1860
- } else {
1861
- topScopes.set(++maxTopScopeId, scope)
1862
- return maxTopScopeId
1863
- }
1946
+ export function mount(parentElement: Element, func: () => void) {
1947
+ new MountScope(parentElement, func);
1864
1948
  }
1865
1949
 
1866
1950
  /**
1867
- * Unmount one specific or all top-level mounts or observes, meaning those that were created outside of the scope
1868
- * of any other mount or observe.
1869
- * @param id Optional mount number (as returned by `mount`, `observe` or `immediateObserve`). If `undefined`, unmount all.
1951
+ * Removes all Aberdeen-managed DOM nodes and stops all active reactive scopes
1952
+ * (created by {@link mount}, {@link observe}, {@link $} with functions, etc.).
1953
+ *
1954
+ * This effectively cleans up the entire Aberdeen application state.
1870
1955
  */
1871
- export function unmount(id?: number) {
1872
- if (id == null) {
1873
- for(let scope of topScopes.values()) scope._remove()
1874
- topScopes.clear()
1875
- } else {
1876
- let scope = topScopes.get(id)
1877
- if (!scope) throw new Error("No such mount "+id)
1878
- scope._remove()
1879
- }
1956
+ export function unmountAll() {
1957
+ ROOT_SCOPE.remove();
1958
+ cssCount = 0;
1880
1959
  }
1881
1960
 
1882
-
1883
- /** Runs the given function, while not subscribing the current scope when reading {@link Store.Store} values.
1961
+ /**
1962
+ * Executes a function *without* creating subscriptions in the current reactive scope, and returns its result.
1884
1963
  *
1885
- * @param func Function to be executed immediately.
1886
- * @returns Whatever `func()` returns.
1887
- * @example
1888
- * ```
1889
- * import {Store, peek, text} from aberdeen
1964
+ * This is useful when you need to access reactive data inside a reactive scope (like {@link observe})
1965
+ * but do not want changes to that specific data to trigger a re-execute of the scope.
1890
1966
  *
1891
- * let store = new Store(['a', 'b', 'c'])
1967
+ * @template T The type of the return value of your function.
1892
1968
  *
1893
- * mount(document.body, () => {
1894
- * // Prevent rerender when store changes
1895
- * let msg = peek(() => `Store has ${store.count()} elements, and the first is ${store.get(0)}`))
1896
- * text(msg)
1897
- * })
1969
+ * @param func - The function to execute without creating subscriptions.
1970
+ * @returns Whatever `func` returns.
1971
+ *
1972
+ * @example Peeking within observe
1973
+ * ```typescript
1974
+ * const data = proxy({ a: 1, b: 2 });
1975
+ * observe(() => {
1976
+ * // re-executes only when data.a changes, because data.b is peeked.
1977
+ * const b = peek(() => data.b);
1978
+ * console.log(`A is ${data.a}, B was ${b} when A changed.`);
1979
+ * });
1980
+ * data.b = 3; // Does not trigger console.log
1981
+ * data.a = 2; // Triggers console.log (logs "A is 2, B was 3 when A changed.")
1898
1982
  * ```
1899
1983
  *
1900
- * In the above example `store.get(0)` could be replaced with `store.peek(0)` to achieve the
1901
- * same result without `peek()` wrapping everything. There is no non-subscribing equivalent
1902
- * for `count()` however.
1903
1984
  */
1904
1985
  export function peek<T>(func: () => T): T {
1905
- let savedScope = currentScope
1906
- currentScope = undefined
1986
+ peeking++;
1907
1987
  try {
1908
- return func()
1988
+ return func();
1909
1989
  } finally {
1910
- currentScope = savedScope
1990
+ peeking--;
1911
1991
  }
1912
1992
  }
1913
1993
 
1994
+ /** When using an object as `source`. */
1995
+ export function map<IN,OUT>(source: Record<string|symbol,IN>, func: (value: IN, index: string|symbol) => undefined|OUT): Record<string|symbol,OUT>;
1996
+ /** When using an array as `source`. */
1997
+ export function map<IN,OUT>(source: Array<IN>, func: (value: IN, index: number) => undefined|OUT): Array<OUT>;
1998
+ /**
1999
+ * Reactively maps/filters items from a proxied source array or object to a new proxied array or object.
2000
+ *
2001
+ * It iterates over the `target` proxy. For each item, it calls `func`.
2002
+ * - If `func` returns a value, it's added to the result proxy under the same key/index.
2003
+ * - If `func` returns `undefined`, the item is skipped (filtered out).
2004
+ *
2005
+ * The returned proxy automatically updates when:
2006
+ * - Items are added/removed/updated in the `target` proxy.
2007
+ * - Any proxied data read *within* the `func` call changes (for a specific item).
2008
+ *
2009
+ * @param func - A function `(value, key) => mappedValue | undefined` that transforms each item.
2010
+ * It receives the item's value and its key/index. Return `undefined` to filter the item out.
2011
+ * @returns A new proxied array or object containing the mapped values.
2012
+ * @template IN The type of items in the source proxy.
2013
+ * @template OUT The type of items in the resulting proxy.
2014
+ *
2015
+ * @example Map array values
2016
+ * ```typescript
2017
+ * const numbers = proxy([1, 2, 3]);
2018
+ * const doubled = map(numbers, (n) => n * 2);
2019
+ * // doubled is proxy([2, 4, 6])
2020
+ *
2021
+ * observe(() => console.log(doubled)); // Logs updates
2022
+ * numbers.push(4); // doubled becomes proxy([2, 4, 6, 8])
2023
+ * ```
2024
+ *
2025
+ * @example Filter and map object properties
2026
+ * ```typescript
2027
+ * const users = proxy({
2028
+ * 'u1': { name: 'Alice', active: true },
2029
+ * 'u2': { name: 'Bob', active: false },
2030
+ * 'u3': { name: 'Charlie', active: true }
2031
+ * });
2032
+ *
2033
+ * const activeUserNames = map(users, (user) => user.active ? user.name : undefined);
2034
+ * // activeUserNames is proxy({ u1: 'Alice', u3: 'Charlie' })
2035
+ * observe(() => console.log(Object.values(activeUserNames)));
2036
+ *
2037
+ * users.u2.active = true;
2038
+ * // activeUserNames becomes proxy({ u1: 'Alice', u2: 'Bob', u3: 'Charlie' })
2039
+ * ```
2040
+ */
2041
+ export function map(source: any, func: (value: DatumType, key: any) => any): any {
2042
+ let out = optProxy(source instanceof Array ? [] : {});
2043
+ onEach(source, (item: DatumType, key: symbol|string|number) => {
2044
+ let value = func(item, key);
2045
+ if (value !== undefined) {
2046
+ out[key] = value;
2047
+ clean(() => {
2048
+ delete out[key];
2049
+ })
2050
+ }
2051
+ })
2052
+ return out
2053
+ }
1914
2054
 
1915
- /*
1916
- * Helper functions
2055
+ /** When using an array as `source`. */
2056
+ export function multiMap<IN,OUT extends {[key: string|symbol]: DatumType}>(source: Array<IN>, func: (value: IN, index: number) => OUT | undefined): OUT;
2057
+ /** When using an object as `source`. */
2058
+ export function multiMap<K extends string|number|symbol,IN,OUT extends {[key: string|symbol]: DatumType}>(source: Record<K,IN>, func: (value: IN, index: K) => OUT | undefined): OUT;
2059
+ /**
2060
+ * Reactively maps items from a source proxy (array or object) to a target proxied object,
2061
+ * where each source item can contribute multiple key-value pairs to the target.
2062
+ *
2063
+ * It iterates over the `target` proxy. For each item, it calls `func`.
2064
+ * - If `func` returns an object, all key-value pairs from that object are added to the result proxy.
2065
+ * - If `func` returns `undefined`, the item contributes nothing.
2066
+ *
2067
+ * The returned proxy automatically updates when:
2068
+ * - Items are added/removed/updated in the `target` proxy.
2069
+ * - Any proxied data read *within* the `func` call changes (for a specific item).
2070
+ * - If multiple input items produce the same output key, the last one processed usually "wins",
2071
+ * but the exact behavior on collision depends on update timing.
2072
+ *
2073
+ * This is useful for "flattening" or "indexing" data, or converting an observable array to an observable object.
2074
+ *
2075
+ * @param source - The source proxied array or object.
2076
+ * @param func - A function `(value, key) => ({...pairs} | undefined)` that transforms an item
2077
+ * into an object of key-value pairs to add, or `undefined` to add nothing.
2078
+ * @returns A new proxied object containing the aggregated key-value pairs.
2079
+ * @template IN The type of items in the source proxy.
2080
+ * @template OUT The type of the aggregated output object (should encompass all possible key-value pairs).
2081
+ *
2082
+ * @example Creating an index from an array
2083
+ * ```typescript
2084
+ * const items = proxy([
2085
+ * { id: 'a', value: 10 },
2086
+ * { id: 'b', value: 20 },
2087
+ * ]);
2088
+ * const itemsById = multiMap(items, (item) => ({
2089
+ * [item.id]: item.value,
2090
+ * [item.id+item.id]: item.value*10,
2091
+ * }));
2092
+ * // itemsById is proxy({ a: 10, aa: 100, b: 20, bb: 200 })
2093
+ *
2094
+ * $(() => console.log(itemsById));
2095
+ *
2096
+ * items.push({ id: 'c', value: 30 });
2097
+ * // itemsById becomes proxy({ a: 10, aa: 100, b: 20, bb: 200, c: 30, cc: 300 })
2098
+ * ```
1917
2099
  */
2100
+ export function multiMap(source: any, func: (value: DatumType, key: any) => Record<string|symbol,DatumType>): any {
2101
+ let out = optProxy({});
2102
+ onEach(source, (item: DatumType, key: symbol|string|number) => {
2103
+ let pairs = func(item, key);
2104
+ if (pairs) {
2105
+ for(let key in pairs) out[key] = pairs[key];
2106
+ clean(() => {
2107
+ for(let key in pairs) delete out[key];
2108
+ })
2109
+ }
2110
+ })
2111
+ return out
2112
+ }
1918
2113
 
1919
- function applyProp(el: Element, prop: any, value: any) {
1920
- if (prop==='create') {
1921
- if (onCreateEnabled) {
1922
- if (typeof value === 'function') {
1923
- value(el)
1924
- } else {
1925
- el.classList.add(value)
1926
- setTimeout(function(){el.classList.remove(value)}, 0)
2114
+ /** When using an object as `array`. */
2115
+ export function partition<OUT_K extends string|number|symbol, IN_V>(source: IN_V[], func: (value: IN_V, key: number) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<number,IN_V>>;
2116
+ /** When using an object as `source`. */
2117
+ export function partition<IN_K extends string|number|symbol, OUT_K extends string|number|symbol, IN_V>(source: Record<IN_K,IN_V>, func: (value: IN_V, key: IN_K) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<IN_K,IN_V>>;
2118
+
2119
+ /**
2120
+ * @overload
2121
+ * Reactively partitions items from a source proxy (array or object) into multiple "bucket" proxies
2122
+ * based on keys determined by a classifier function.
2123
+ *
2124
+ * This function iterates through the `source` proxy using {@link onEach}. For each item,
2125
+ * it calls the classifier `func`, which should return:
2126
+ * - A single key (`OUT_K`): The item belongs to the bucket with this key.
2127
+ * - An array of keys (`OUT_K[]`): The item belongs to all buckets specified in the array.
2128
+ * - `undefined`: The item is not placed in any bucket.
2129
+ *
2130
+ * The function returns a main proxied object. The keys of this object are the bucket keys (`OUT_K`)
2131
+ * returned by `func`. Each value associated with a bucket key is another proxied object (the "bucket").
2132
+ * This inner bucket object maps the *original* keys/indices from the `source` to the items
2133
+ * themselves that were classified into that bucket.
2134
+ *
2135
+ * The entire structure is reactive. Changes in the `source` proxy (adding/removing/updating items)
2136
+ * or changes in dependencies read by the `func` will cause the output partitioning to update automatically.
2137
+ * Buckets are created dynamically as needed and removed when they become empty.
2138
+ *
2139
+ * @param source - The input proxied Array or Record (e.g., created by {@link proxy}) containing the items to partition.
2140
+ * @param func - A classifier function `(value: IN_V, key: IN_K | number) => undefined | OUT_K | OUT_K[]`.
2141
+ * It receives the item's value and its original key/index from the `source`. It returns the bucket key(s)
2142
+ * the item belongs to, or `undefined` to ignore the item.
2143
+ * @returns A proxied object where keys are the bucket identifiers (`OUT_K`) and values are proxied Records
2144
+ * (`Record<IN_K | number, IN_V>`) representing the buckets. Each bucket maps original source keys/indices
2145
+ * to the items belonging to that bucket.
2146
+ *
2147
+ * @template OUT_K - The type of the keys used for the output buckets (string, number, or symbol).
2148
+ * @template IN_V - The type of the values in the source proxy.
2149
+ * @template IN_K - The type of the keys in the source proxy (if it's a Record).
2150
+ *
2151
+ * @example Grouping items by a property
2152
+ * ```typescript
2153
+ * interface Product { id: string; category: string; name: string; }
2154
+ * const products = proxy<Product[]>([
2155
+ * { id: 'p1', category: 'Fruit', name: 'Apple' },
2156
+ * { id: 'p2', category: 'Veg', name: 'Carrot' },
2157
+ * { id: 'p3', category: 'Fruit', name: 'Banana' },
2158
+ * ]);
2159
+ *
2160
+ * // Partition products by category. Output keys are categories (string).
2161
+ * // Inner keys are original array indices (number).
2162
+ * const productsByCategory = partition(products, (product) => product.category);
2163
+ *
2164
+ * // Reactively show the data structure
2165
+ * dump(productsByCategory);
2166
+ *
2167
+ * // Make random changes to the categories, to show reactiveness
2168
+ * setInterval(() => products[0|(Math.random()*3)].category = ['Snack','Fruit','Veg'][0|(Math.random()*3)], 2000);
2169
+ * ```
2170
+ *
2171
+ * @example Item in multiple buckets
2172
+ * ```typescript
2173
+ * interface User { id: number; tags: string[]; name: string; }
2174
+ * const users = proxy({
2175
+ * 'u1': { name: 'Alice', tags: ['active', 'new'] },
2176
+ * 'u2': { name: 'Bob', tags: ['active'] }
2177
+ * });
2178
+ *
2179
+ * // Partition users by tag. Output keys are tags (string).
2180
+ * // Inner keys are original object keys (string: 'u1', 'u2').
2181
+ * const usersByTag = partition(users, (user) => user.tags);
2182
+ *
2183
+ * console.log(usersByTag);
2184
+ * ```
2185
+ */
2186
+ export function partition<IN_K extends string|number|symbol, OUT_K extends string|number|symbol, IN_V>(source: Record<IN_K,IN_V>, func: (value: IN_V, key: IN_K) => undefined | OUT_K | OUT_K[]): Record<OUT_K,Record<IN_K,IN_V>> {
2187
+ const unproxiedOut = {} as Record<OUT_K,Record<IN_K,IN_V>>;
2188
+ const out = proxy(unproxiedOut);
2189
+ onEach(source, (item: IN_V, key: IN_K) => {
2190
+ let rsp = func(item, key);
2191
+ if (rsp != null) {
2192
+ const buckets = rsp instanceof Array ? rsp : [rsp];
2193
+ if (buckets.length) {
2194
+ for(let bucket of buckets) {
2195
+ if (unproxiedOut[bucket]) out[bucket][key] = item;
2196
+ else out[bucket] = {[key]: item} as Record<IN_K, IN_V>;
2197
+ }
2198
+ clean(() => {
2199
+ for(let bucket of buckets) {
2200
+ delete out[bucket][key];
2201
+ if (isObjEmpty(unproxiedOut[bucket])) delete out[bucket];
2202
+ }
2203
+ })
1927
2204
  }
1928
2205
  }
1929
- } else if (prop==='destroy') {
1930
- onDestroyMap.set(el, value)
1931
- } else if (typeof value === 'function') {
1932
- // Set an event listener; remove it again on clean.
1933
- el.addEventListener(prop, value)
1934
- clean(() => el.removeEventListener(prop, value))
1935
- } else if (prop==='value' || prop==='className' || prop==='selectedIndex' || value===true || value===false) {
1936
- // All boolean values and a few specific keys should be set as a property
1937
- (el as any)[prop] = value
1938
- } else if (prop==='text') {
1939
- // `text` is set as textContent
1940
- el.textContent = value
1941
- } else if ((prop==='class' || prop==='className') && typeof value === 'object') {
1942
- // Allow setting classes using an object where the keys are the names and
1943
- // the values are booleans stating whether to set or remove.
1944
- for(let name in value) {
1945
- if (value[name]) el.classList.add(name)
1946
- else el.classList.remove(name)
1947
- }
1948
- } else if (prop==='style' && typeof value === 'object') {
1949
- // `style` can receive an object
1950
- Object.assign((<HTMLElement>el).style, value)
1951
- } else {
1952
- // Everything else is an HTML attribute
1953
- el.setAttribute(prop, value)
1954
- }
2206
+ })
2207
+ return out;
1955
2208
  }
1956
2209
 
1957
- function valueToData(value: any) {
1958
- if (typeof value !== "object" || !value) {
1959
- // Simple data types
1960
- return value
1961
- } else if (value instanceof Store) {
1962
- // When a Store is passed pointing at a collection, a reference
1963
- // is made to that collection.
1964
- return value._observe()
1965
- } else if (value instanceof Map) {
1966
- let result = new ObsMap()
1967
- value.forEach((v,k) => {
1968
- let d = valueToData(v)
1969
- if (d!==undefined) result.rawSet(k, d)
2210
+
2211
+ /**
2212
+ * Renders a live, recursive dump of a proxied data structure (or any value)
2213
+ * into the DOM at the current {@link $} insertion point.
2214
+ *
2215
+ * Uses `<ul>` and `<li>` elements to display object properties and array items.
2216
+ * Updates reactively if the dumped data changes. Primarily intended for debugging purposes.
2217
+ *
2218
+ * @param data - The proxied data structure (or any value) to display.
2219
+ * @returns The original `data` argument, allowing for chaining.
2220
+ * @template T - The type of the data being dumped.
2221
+ *
2222
+ * @example Dumping reactive state
2223
+ * ```typescript
2224
+ * import { $, proxy, dump } from 'aberdeen';
2225
+ *
2226
+ * const state = proxy({
2227
+ * user: { name: 'Frank', kids: 1 },
2228
+ * items: ['a', 'b']
2229
+ * });
2230
+ *
2231
+ * $('h2:Live State Dump');
2232
+ * dump(state);
2233
+ *
2234
+ * // Change state later, the dump in the DOM will update
2235
+ * setTimeout(() => { state.user.kids++; state.items.push('c'); }, 2000);
2236
+ * ```
2237
+ */
2238
+ export function dump<T>(data: T): T {
2239
+ if (data && typeof data === 'object') {
2240
+ $({text: data instanceof Array ? "<array>" : "<object>"});
2241
+ $('ul', () => {
2242
+ onEach(data as any, (value, key) => {
2243
+ $('li:'+JSON.stringify(key)+": ", () => {
2244
+ dump(value)
2245
+ })
2246
+ })
1970
2247
  })
1971
- return result
1972
- }
1973
- else if (value instanceof Array) {
1974
- let result = new ObsArray()
1975
- for(let i=0; i<value.length; i++) {
1976
- let d = valueToData(value[i])
1977
- if (d!==undefined) result.rawSet(i, d)
1978
- }
1979
- return result
1980
- } else if (value.constructor === Object) {
1981
- // A plain (literal) object
1982
- let result = new ObsObject()
1983
- for(let k in value) {
1984
- let d = valueToData(value[k])
1985
- if (d!==undefined) result.rawSet(k, d)
1986
- }
1987
- return result
1988
2248
  } else {
1989
- // Any other type of object (including ObsCollection)
1990
- return value
2249
+ $({text: JSON.stringify(data)})
1991
2250
  }
2251
+ return data
1992
2252
  }
1993
2253
 
1994
- function defaultMakeSortKey(store: Store) {
1995
- return store.index()
1996
- }
2254
+ /*
2255
+ * Helper functions
2256
+ */
1997
2257
 
1998
2258
  /* c8 ignore start */
1999
- function internalError(code: number) {
2000
- let error = new Error("Aberdeen internal error "+code)
2001
- setTimeout(() => { throw error }, 0)
2259
+ function internalError(code: number): never {
2260
+ throw new Error("Aberdeen internal error "+code);
2002
2261
  }
2003
2262
  /* c8 ignore end */
2004
2263
 
2005
- function handleError(e: any) {
2006
- // Throw the error async, so the rest of the rendering can continue
2007
- setTimeout(() => {throw e}, 0)
2008
- }
2009
-
2010
- class ScopeError extends Error {
2011
- constructor(mount: boolean) {
2012
- super(`Operation not permitted outside of ${mount ? "a mount" : "an observe"}() scope`)
2264
+ function handleError(e: any, showMessage: boolean) {
2265
+ try {
2266
+ if (onError(e) === false) showMessage = false;
2267
+ } catch (e) {
2268
+ console.error(e);
2269
+ }
2270
+ try {
2271
+ if (showMessage) $('div.aberdeen-error:Error');
2272
+ } catch {
2273
+ // Error while adding the error marker to the DOM. Apparently, we're in
2274
+ // an awkward context. The error should already have been logged by
2275
+ // onError, so let's not confuse things by generating more errors.
2013
2276
  }
2014
2277
  }
2015
2278
 
2016
2279
  /** @internal */
2017
- export function withEmitHandler(handler: (this: ObsCollection, index: any, newData: DatumType, oldData: DatumType) => void, func: ()=>void) {
2018
- const oldEmitHandler = ObsCollection.prototype.emitChange
2019
- ObsCollection.prototype.emitChange = handler
2280
+ export function withEmitHandler(handler: (target: TargetType, index: any, newData: DatumType, oldData: DatumType) => void, func: ()=>void) {
2281
+ const oldEmitHandler = emit;
2282
+ emit = handler;
2020
2283
  try {
2021
- func()
2284
+ func();
2022
2285
  } finally {
2023
- ObsCollection.prototype.emitChange = oldEmitHandler
2286
+ emit = oldEmitHandler;
2024
2287
  }
2025
2288
  }
2026
2289
 
2027
- /**
2028
- * Run a function, while *not* causing reactive effects for any changes it makes to `Store`s.
2029
- * @param func The function to be executed once immediately.
2030
- */
2031
- export function inhibitEffects(func: () => void) {
2032
- withEmitHandler(() => {}, func)
2033
- }
2034
-
2035
2290
  // @ts-ignore
2036
2291
  // c8 ignore next
2037
2292
  if (!String.prototype.replaceAll) String.prototype.replaceAll = function(from, to) { return this.split(from).join(to) }
2293
+ declare global {
2294
+ interface String {
2295
+ replaceAll(from: string, to: string): string;
2296
+ }
2297
+ }