aberdeen 0.2.4 → 0.4.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
@@ -11,11 +11,13 @@ interface QueueRunner {
11
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 queueArray: Array<QueueRunner> = [] // When not empty, a runQueue is scheduled or currently running.
15
+ let queueIndex = 0 // This first element in queueArray that still needs to be processed.
16
+ let queueSet: Set<QueueRunner> = new Set() // Contains the subset of queueArray at index >= queueIndex.
17
+ let queueOrdered = true // Set to `false` when `queue()` appends a runner to `queueArray` that should come before the previous last item in the array. Will trigger a sort.
18
+ 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.
19
+ let showCreateTransitions = false // Set to `true` only when creating top level elements in response to `Store` changes, triggering `create` transitions.
20
+
19
21
 
20
22
  /** @internal */
21
23
  export type Patch = Map<ObsCollection, Map<any, [any, any]>>;
@@ -35,9 +37,18 @@ function queue(runner: QueueRunner) {
35
37
  queueSet.add(runner)
36
38
  }
37
39
 
38
- function runQueue(): void {
39
- onCreateEnabled = true
40
- for(queueIndex = 0; queueIndex < queueArray.length; ) {
40
+ /**
41
+ * Normally, changes to `Store`s are reacted to asynchronously, in an (optimized)
42
+ * batch, after a timeout of 0s. Calling `runQueue()` will do so immediately
43
+ * and synchronously. Doing so may be helpful in cases where you need some DOM
44
+ * modification to be done synchronously.
45
+ *
46
+ * This function is re-entrant, meaning it is safe to call `runQueue` from a
47
+ * function that is called due to another (automatic) invocation of `runQueue`.
48
+ */
49
+ export function runQueue(): void {
50
+ showCreateTransitions = true
51
+ for(; queueIndex < queueArray.length; ) {
41
52
  // Sort queue if new unordered items have been added since last time.
42
53
  if (!queueOrdered) {
43
54
  queueArray.splice(0, queueIndex)
@@ -49,8 +60,8 @@ function runQueue(): void {
49
60
 
50
61
  // Process the rest of what's currently in the queue.
51
62
  let batchEndIndex = queueArray.length
52
- for(; queueIndex < batchEndIndex && queueOrdered; queueIndex++) {
53
- let runner = queueArray[queueIndex]
63
+ while(queueIndex < batchEndIndex && queueOrdered) {
64
+ let runner = queueArray[queueIndex++]
54
65
  queueSet.delete(runner)
55
66
  runner._queueRun()
56
67
  }
@@ -60,19 +71,20 @@ function runQueue(): void {
60
71
  runQueueDepth++
61
72
  }
62
73
 
74
+ queueIndex = 0
63
75
  queueArray.length = 0
64
- queueIndex = undefined
65
76
  runQueueDepth = 0
66
- onCreateEnabled = false
77
+ showCreateTransitions = false
67
78
  }
68
79
 
69
80
 
81
+ let domWaiters: (() => void)[] = []
82
+ let domInReadPhase = false
83
+
70
84
  /**
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.
85
+ * A promise-like object that you can `await`. It will resolve *after* the current batch
86
+ * of DOM-write operations has completed. This is the best time to retrieve DOM properties
87
+ * that dependent on a layout being completed, such as `offsetHeight`.
76
88
  *
77
89
  * By batching DOM reads separately from DOM writes, this prevents the browser from
78
90
  * interleaving layout reads and writes, which can force additional layout recalculations.
@@ -81,20 +93,25 @@ function runQueue(): void {
81
93
  *
82
94
  * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM read
83
95
  * operations happen before any DOM writes in the same queue cycle, minimizing layout thrashing.
84
- *
85
- * @param func The function to be executed as a DOM read operation.
96
+ *
97
+ * See `transitions.js` for some examples.
86
98
  */
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
99
 
100
+ export const DOM_READ_PHASE = {
101
+ then: function(fulfilled: () => void) {
102
+ if (domInReadPhase) fulfilled()
103
+ else {
104
+ if (!domWaiters.length) queue(DOM_PHASE_RUNNER)
105
+ domWaiters.push(fulfilled)
106
+ }
107
+ return this
108
+ }
109
+ }
92
110
  /**
93
- * Schedule a DOM write operation to be executed in Aberdeen's internal task queue.
94
- *
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.
111
+ * A promise-like object that you can `await`. It will resolve *after* the current
112
+ * DOM_READ_PHASE has completed (if any) and after any DOM triggered by Aberdeen
113
+ * have completed. This is a good time to do little manual DOM tweaks that depend
114
+ * on a *read phase* first, like triggering transitions.
98
115
  *
99
116
  * By batching DOM writes separately from DOM reads, this prevents the browser from
100
117
  * interleaving layout reads and writes, which can force additional layout recalculations.
@@ -104,11 +121,34 @@ export function scheduleDomReader(func: () => void): void {
104
121
  * Unlike `setTimeout` or `requestAnimationFrame`, this mechanism ensures that DOM write
105
122
  * operations happen after all DOM reads in the same queue cycle, minimizing layout thrashing.
106
123
  *
107
- * @param func The function to be executed as a DOM write operation.
124
+ * See `transitions.js` for some examples.
108
125
  */
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})
126
+
127
+ export const DOM_WRITE_PHASE = {
128
+ then: function(fulfilled: () => void) {
129
+ if (!domInReadPhase) fulfilled()
130
+ else {
131
+ if (!domWaiters.length) queue(DOM_PHASE_RUNNER)
132
+ domWaiters.push(fulfilled)
133
+ }
134
+ return this
135
+ }
136
+ }
137
+
138
+ const DOM_PHASE_RUNNER = {
139
+ _queueOrder: 99999,
140
+ _queueRun: function() {
141
+ let waiters = domWaiters
142
+ domWaiters = []
143
+ domInReadPhase = !domInReadPhase
144
+ for(let waiter of waiters) {
145
+ try {
146
+ waiter()
147
+ } catch(e) {
148
+ console.error(e)
149
+ }
150
+ }
151
+ }
112
152
  }
113
153
 
114
154
 
@@ -172,15 +212,6 @@ interface Observer {
172
212
  */
173
213
 
174
214
  abstract class Scope implements QueueRunner, Observer {
175
- _parentElement: Element | undefined
176
-
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
180
-
181
- // The node or scope right before this scope that has the same `parentElement`
182
- _precedingSibling: Node | Scope | undefined
183
-
184
215
  // The last child node or scope within this scope that has the same `parentElement`
185
216
  _lastChild: Node | Scope | undefined
186
217
 
@@ -193,13 +224,13 @@ abstract class Scope implements QueueRunner, Observer {
193
224
  _isDead: boolean = false
194
225
 
195
226
  constructor(
196
- parentElement: Element | undefined,
197
- precedingSibling: Node | Scope | undefined,
198
- queueOrder: number,
227
+ public _parentElement: Element | undefined,
228
+ // The node or scope right before this scope that has the same `parentElement`
229
+ public _precedingSibling: Node | Scope | undefined,
230
+ // How deep is this scope nested in other scopes; we use this to make sure events
231
+ // at lower depths are handled before events at higher depths.
232
+ public _queueOrder: number,
199
233
  ) {
200
- this._parentElement = parentElement
201
- this._precedingSibling = precedingSibling
202
- this._queueOrder = queueOrder
203
234
  }
204
235
 
205
236
  // Get a reference to the last Node preceding this Scope, or undefined if there is none
@@ -223,10 +254,9 @@ abstract class Scope implements QueueRunner, Observer {
223
254
  }
224
255
 
225
256
  _addNode(node: Node) {
226
- if (!this._parentElement) throw new ScopeError(true)
227
257
  let prevNode = this._findLastNode() || this._findPrecedingNode()
228
258
 
229
- this._parentElement.insertBefore(node, prevNode ? prevNode.nextSibling : this._parentElement.firstChild)
259
+ this._parentElement!.insertBefore(node, prevNode ? prevNode.nextSibling : this._parentElement!.firstChild)
230
260
  this._lastChild = node
231
261
  }
232
262
 
@@ -288,18 +318,23 @@ abstract class Scope implements QueueRunner, Observer {
288
318
  }
289
319
 
290
320
  class SimpleScope extends Scope {
291
- _renderer: () => void
292
-
293
321
  constructor(
294
322
  parentElement: Element | undefined,
295
323
  precedingSibling: Node | Scope | undefined,
296
324
  queueOrder: number,
297
- renderer: () => void,
325
+ renderer?: () => void,
298
326
  ) {
299
327
  super(parentElement, precedingSibling, queueOrder)
300
- this._renderer = renderer
328
+ if (renderer) this._renderer = renderer
301
329
  }
302
330
 
331
+ /* c8 ignore start */
332
+ _renderer() {
333
+ // Should be overriden by a subclass or the constructor
334
+ internalError(14)
335
+ }
336
+ /* c8 ignore stop */
337
+
303
338
  _queueRun() {
304
339
  /* c8 ignore next */
305
340
  if (currentScope) internalError(2)
@@ -318,10 +353,44 @@ class SimpleScope extends Scope {
318
353
  this._renderer()
319
354
  } catch(e) {
320
355
  // Throw the error async, so the rest of the rendering can continue
321
- handleError(e)
356
+ handleError(e, true)
322
357
  }
323
358
  currentScope = savedScope
324
359
  }
360
+
361
+ _install() {
362
+ if (showCreateTransitions) {
363
+ showCreateTransitions = false
364
+ this._update()
365
+ showCreateTransitions = true
366
+ } else {
367
+ this._update()
368
+ }
369
+ // Add it to our list of cleaners. Even if `childScope` currently has
370
+ // no cleaners, it may get them in a future refresh.
371
+ currentScope!._cleaners.push(this)
372
+ }
373
+ }
374
+
375
+ /**
376
+ * This could have been done with a SimpleScope, but then we'd have to draw along an instance of
377
+ * that as well as a renderer function that closes over quite a few variables, which probably
378
+ * wouldn't be great for the performance of this common feature.
379
+ */
380
+ class SetArgScope extends SimpleScope {
381
+ constructor(
382
+ parentElement: Element | undefined,
383
+ precedingSibling: Node | Scope | undefined,
384
+ queueOrder: number,
385
+ private _key: string,
386
+ private _value: Store,
387
+ ) {
388
+ super(parentElement, precedingSibling, queueOrder)
389
+ }
390
+
391
+ _renderer() {
392
+ applyArg(this._parentElement as Element, this._key, this._value.get())
393
+ }
325
394
  }
326
395
 
327
396
  let immediateQueue: Set<Scope> = new Set()
@@ -607,7 +676,7 @@ class OnEachItemScope extends Scope {
607
676
  try {
608
677
  sortKey = this._parent._makeSortKey(itemStore)
609
678
  } catch(e) {
610
- handleError(e)
679
+ handleError(e, false)
611
680
  }
612
681
 
613
682
  let oldSortStr: string = this._sortStr
@@ -625,7 +694,7 @@ class OnEachItemScope extends Scope {
625
694
  try {
626
695
  this._parent._renderer(itemStore)
627
696
  } catch(e) {
628
- handleError(e)
697
+ handleError(e, true)
629
698
  }
630
699
  }
631
700
 
@@ -636,7 +705,7 @@ class OnEachItemScope extends Scope {
636
705
 
637
706
  /**
638
707
  * This global is set during the execution of a `Scope.render`. It is used by
639
- * functions like `node`, `text` and `clean`.
708
+ * functions like `$` and `clean`.
640
709
  */
641
710
  let currentScope: Scope | undefined
642
711
 
@@ -914,56 +983,98 @@ class ObsMap extends ObsCollection {
914
983
 
915
984
 
916
985
 
917
- /**
918
- * A data store that automatically subscribes the current scope to updates
986
+ const DETACHED_KEY: any = {}
987
+
988
+ /*
989
+ * A data store that automatically subscribes the current Scope to updates
919
990
  * whenever data is read from it.
920
991
  *
921
992
  * Supported data types are: `string`, `number`, `boolean`, `undefined`, `null`,
922
993
  * `Array`, `object` and `Map`. The latter three will always have `Store` objects as
923
994
  * values, creating a tree of `Store`-objects.
924
995
  */
996
+
997
+ export interface Store {
998
+ /**
999
+ * Return a `Store` deeper within the tree by resolving the given `path`,
1000
+ * subscribing to every level.
1001
+ * In case `undefined` is encountered while resolving the path, a newly
1002
+ * created `Store` containing `undefined` is returned. In that case, the
1003
+ * `Store`'s [[`isDetached`]] method will return `true`.
1004
+ * In case something other than a collection is encountered, an error is thrown.
1005
+ */
1006
+ (...path: any[]): Store
1007
+ }
925
1008
 
926
1009
  export class Store {
927
1010
  /** @internal */
1011
+ // @ts-ignore
928
1012
  private _collection: ObsCollection
929
1013
  /** @internal */
930
1014
  private _idx: any
1015
+ /** @internal */
1016
+ private _virtual: string[] | undefined
931
1017
 
932
1018
  /**
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
- */
1019
+ * Create a new `Store` with `undefined` as its initial value.
1020
+ */
944
1021
  constructor()
1022
+ /**
1023
+ * Create a new `Store`.
1024
+ * @param value The initial value. Plain objects, arrays and `Map`s, are converted
1025
+ * into a tree of nested `Store`s. When another `Store` is included somewhere in that
1026
+ * input tree, a reference is made.
1027
+ */
945
1028
  constructor(value: any)
1029
+
946
1030
  /** @internal */
947
1031
  constructor(collection: ObsCollection, index: any)
948
1032
 
1033
+ /** @internal */
949
1034
  constructor(value: any = undefined, index: any = undefined) {
1035
+ /**
1036
+ * Create and return a new `Store` that represents the subtree at `path` of
1037
+ * the current `Store`.
1038
+ *
1039
+ * The `path` is only actually resolved when this new `Store` is first used,
1040
+ * and how this is done depends on whether a read or a write operation is
1041
+ * performed. Read operations will just use an `undefined` value when a
1042
+ * subtree that we're diving into does not exist. Also, they'll subscribe
1043
+ * to changes at each level of the tree indexed by `path`.
1044
+ *
1045
+ * Write operations will create any missing subtrees as objects. They don't
1046
+ * subscribe to changes (as they are the ones causing the changes).
1047
+ *
1048
+ * Both read and write operations will throw an error if, while resolving
1049
+ * `path`, they encounters a non-collection data type (such as a number)
1050
+ */
1051
+ const ref: Store = function(...path: any): Store {
1052
+ const result = new Store(ref._collection, ref._idx)
1053
+ if (path.length || ref._virtual) {
1054
+ result._virtual = ref._virtual ? ref._virtual.concat(path) : path
1055
+ }
1056
+ return result
1057
+ } as Store
1058
+
1059
+ Object.setPrototypeOf(ref, Store.prototype)
950
1060
  if (index===undefined) {
951
- this._collection = new ObsArray()
952
- this._idx = 0
1061
+ ref._collection = new ObsArray()
1062
+ ref._idx = 0
953
1063
  if (value!==undefined) {
954
- this._collection.rawSet(0, valueToData(value))
1064
+ ref._collection.rawSet(0, valueToData(value))
955
1065
  }
956
1066
  } else {
957
1067
  if (!(value instanceof ObsCollection)) {
958
1068
  throw new Error("1st parameter should be an ObsCollection if the 2nd is also given")
959
1069
  }
960
- this._collection = value
961
- this._idx = index
1070
+ ref._collection = value
1071
+ ref._idx = index
962
1072
  }
1073
+ // @ts-ignore
1074
+ return ref
963
1075
  }
964
1076
 
965
1077
  /**
966
- *
967
1078
  * @returns The index for this Store within its parent collection. This will be a `number`
968
1079
  * when the parent collection is an array, a `string` when it's an object, or any data type
969
1080
  * when it's a `Map`.
@@ -972,8 +1083,8 @@ export class Store {
972
1083
  * ```
973
1084
  * let store = new Store({x: 123})
974
1085
  * let subStore = store.ref('x')
975
- * assert(subStore.get() === 123)
976
- * assert(subStore.index() === 'x') // <----
1086
+ * subStore.get() // 123
1087
+ * subStore.index() // 'x'
977
1088
  * ```
978
1089
  */
979
1090
  index() {
@@ -985,148 +1096,125 @@ export class Store {
985
1096
  this._collection._removeObserver(this._idx, scope)
986
1097
  }
987
1098
 
988
-
989
1099
  /**
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
- * ```
1100
+ * Retrieve the value for store, subscribing the observe scope to changes.
1101
+ *
1102
+ * @param depth Limit the depth of the retrieved data structure to this positive integer.
1103
+ * When `depth` is `1`, only a single level of the value at `path` is unpacked. This
1104
+ * makes no difference for primitive values (like strings), but for objects, maps and
1105
+ * arrays, it means that each *value* in the resulting data structure will be a
1106
+ * reference to the `Store` for that value.
1107
+ *
1108
+ * @returns The resulting value (or `undefined` if the `Store` does not exist).
1109
+ */
1110
+ get(depth: number = 0): any {
1111
+ let value = this._observe()
1112
+ return value instanceof ObsCollection ? value._getRecursive(depth-1) : value
1113
+ }
1114
+
1115
+ /**
1116
+ * Exactly like {@link Store.get}, except that when executed from an observe scope,
1117
+ * we will not subscribe to changes in the data retrieved data.
999
1118
  */
1000
- get(...path: any[]) : any {
1001
- return this.query({path})
1119
+ peek(depth: number = 0): any {
1120
+
1121
+ let savedScope = currentScope
1122
+ currentScope = undefined
1123
+ let result = this.get(depth)
1124
+ currentScope = savedScope
1125
+ return result
1002
1126
  }
1127
+
1003
1128
 
1004
1129
  /**
1005
- * Like {@link Store.get}, but doesn't subscribe to changes.
1130
+ * Like {@link Store.get}, but with return type checking.
1131
+ *
1132
+ * @param expectType A string specifying what type the.get is expected to return. Options are:
1133
+ * "undefined", "null", "boolean", "number", "string", "function", "array", "map"
1134
+ * and "object". If the store holds a different type of value, a `TypeError`
1135
+ * exception is thrown.
1136
+ * @returns
1006
1137
  */
1007
- peek(...path: any[]): any {
1008
- return this.query({path, peek: true})
1138
+ getTyped(expectType: String, depth: number = 0): any {
1139
+ let value = this._observe()
1140
+ let type = (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
1141
+ if (type !== expectType) throw new TypeError(`Expecting ${expectType} but got ${type}`)
1142
+ return value instanceof ObsCollection ? value._getRecursive(depth-1) : value
1009
1143
  }
1010
1144
 
1011
1145
  /**
1012
1146
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `number`.
1013
1147
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1014
1148
  */
1015
- getNumber(...path: any[]): number { return <number>this.query({path, type: 'number'}) }
1149
+ getNumber(): number { return <number>this.getTyped('number') }
1016
1150
  /**
1017
1151
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `string`.
1018
1152
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1019
1153
  */
1020
- getString(...path: any[]): string { return <string>this.query({path, type: 'string'}) }
1154
+ getString(): string { return <string>this.getTyped('string') }
1021
1155
  /**
1022
1156
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `boolean`.
1023
1157
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1024
1158
  */
1025
- getBoolean(...path: any[]): boolean { return <boolean>this.query({path, type: 'boolean'}) }
1159
+ getBoolean(): boolean { return <boolean>this.getTyped('boolean') }
1026
1160
  /**
1027
1161
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `function`.
1028
1162
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1029
1163
  */
1030
- getFunction(...path: any[]): (Function) { return <Function>this.query({path, type: 'function'}) }
1164
+ getFunction(): (Function) { return <Function>this.getTyped('function') }
1031
1165
  /**
1032
1166
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `array`.
1033
1167
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1034
1168
  */
1035
- getArray(...path: any[]): any[] { return <any[]>this.query({path, type: 'array'}) }
1169
+ getArray(depth: number = 0): any[] { return <any[]>this.getTyped('array', depth) }
1036
1170
  /**
1037
1171
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `object`.
1038
1172
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1039
1173
  */
1040
- getObject(...path: any[]): object { return <object>this.query({path, type: 'object'}) }
1174
+ getObject(depth: number = 0): object { return <object>this.getTyped('object', depth) }
1041
1175
  /**
1042
1176
  * @returns Like {@link Store.get}, but throws a `TypeError` if the resulting value is not of type `map`.
1043
1177
  * Using this instead of just {@link Store.get} is especially useful from within TypeScript.
1044
1178
  */
1045
- getMap(...path: any[]): Map<any,any> { return <Map<any,any>>this.query({path, type: 'map'}) }
1179
+ getMap(depth: number = 0): Map<any,any> { return <Map<any,any>>this.getTyped('map', depth) }
1180
+
1046
1181
 
1047
1182
  /**
1048
- * Like {@link Store.get}, but the first parameter is the default value (returned when the Store
1183
+ * Like {@link Store.get}, but with a default value (returned when the Store
1049
1184
  * contains `undefined`). This default value is also used to determine the expected type,
1050
1185
  * and to throw otherwise.
1051
1186
  *
1052
1187
  * @example
1053
1188
  * ```
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)
1189
+ * let store = new Store({x: 42})
1190
+ * store('x').getOr(99) // 42
1191
+ * store('y').getOr(99) // 99
1192
+ * store('x').getOr('hello') // throws TypeError (because 42 is not a string)
1058
1193
  * ```
1059
1194
  */
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
- }
1068
-
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()
1108
-
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}`)
1195
+ getOr<T>(defaultValue: T): T {
1196
+ let value = this._observe()
1197
+ if (value===undefined) return defaultValue
1198
+
1199
+ let expectType: string = typeof defaultValue
1200
+ if (expectType==='object') {
1201
+ if (defaultValue instanceof Map) expectType = 'map'
1202
+ else if (defaultValue instanceof Array) expectType = 'array'
1203
+ else if (defaultValue === null) expectType = 'null'
1112
1204
  }
1113
- if (value instanceof ObsCollection) {
1114
- return value._getRecursive(opts.depth==null ? -1 : opts.depth-1)
1115
- }
1116
- return value===undefined ? opts.defaultValue : value
1205
+ let type = (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
1206
+ if (type !== expectType) throw new TypeError(`Expecting ${expectType} but got ${type}`)
1207
+ return (value instanceof ObsCollection ? value._getRecursive(-1) : value) as T
1117
1208
  }
1118
1209
 
1119
1210
  /**
1120
- * Checks if the specified collection is empty, and subscribes the current scope to changes of the emptiness of this collection.
1211
+ * Checks if the collection held in `Store` is empty, and subscribes the current scope to changes of the emptiness of this collection.
1121
1212
  *
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.
1213
+ * @returns When the collection is not empty `true` is returned. If it is empty or if the value is undefined, `false` is returned.
1124
1214
  * @throws When the value is not a collection and not undefined, an Error will be thrown.
1125
1215
  */
1126
- isEmpty(...path: any[]): boolean {
1127
- let store = this.ref(...path)
1128
-
1129
- let value = store._observe()
1216
+ isEmpty(): boolean {
1217
+ let value = this._observe()
1130
1218
  if (value instanceof ObsCollection) {
1131
1219
  if (currentScope) {
1132
1220
  let observer = new IsEmptyObserver(currentScope, value, false)
@@ -1142,16 +1230,13 @@ export class Store {
1142
1230
  }
1143
1231
 
1144
1232
  /**
1145
- * Returns the number of items in the specified collection, and subscribes the current scope to changes in this count.
1233
+ * Returns the number of items in the collection held in Store, and subscribes the current scope to changes in this count.
1146
1234
  *
1147
- * @param path Any path terms to resolve before retrieving the value.
1148
1235
  * @returns The number of items contained in the collection, or 0 if the value is undefined.
1149
1236
  * @throws When the value is not a collection and not undefined, an Error will be thrown.
1150
1237
  */
1151
- count(...path: any[]): number {
1152
- let store = this.ref(...path)
1153
-
1154
- let value = store._observe()
1238
+ count(): number {
1239
+ let value = this._observe()
1155
1240
  if (value instanceof ObsCollection) {
1156
1241
  if (currentScope) {
1157
1242
  let observer = new IsEmptyObserver(currentScope, value, true)
@@ -1167,23 +1252,40 @@ export class Store {
1167
1252
  }
1168
1253
 
1169
1254
  /**
1170
- * Returns a strings describing the type of the store value, subscribing to changes of this type.
1255
+ * Returns a strings describing the type of the `Store` value, subscribing to changes of this type.
1171
1256
  * Note: this currently also subscribes to changes of primitive values, so changing a value from 3 to 4
1172
1257
  * would cause the scope to be rerun. This is not great, and may change in the future. This caveat does
1173
1258
  * not apply to changes made *inside* an object, `Array` or `Map`.
1174
1259
  *
1175
- * @param path Any path terms to resolve before retrieving the value.
1176
1260
  * @returns Possible options: "undefined", "null", "boolean", "number", "string", "function", "array", "map" or "object".
1177
1261
  */
1178
- getType(...path: any[]): string {
1179
- let store = this.ref(...path)
1180
- let value = store._observe()
1262
+ getType(): string {
1263
+ let value = this._observe()
1181
1264
  return (value instanceof ObsCollection) ? value._getType() : (value===null ? "null" : typeof value)
1182
1265
  }
1183
1266
 
1184
1267
  /**
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}.
1268
+ * Returns a new `Store` that will always hold either the value of `whenTrue` or the value
1269
+ * of `whenFalse` depending on whether the original `Store` is truthy or not.
1270
+ *
1271
+ * @param whenTrue The value set to the return-`Store` while `this` is truthy. This can be
1272
+ * any type of value. If it's a `Store`, the return-`Store` will reference the same
1273
+ * data (so *no* deep copy will be made).
1274
+ * @param whenFalse Like `whenTrue`, but for falsy values (false, undefined, null, 0, "").
1275
+ * @returns A store holding the result value. The value will keep getting updated while
1276
+ * the observe context from which `if()` was called remains active.
1277
+ */
1278
+ if(whenTrue: any[], whenFalse?: any[]): Store {
1279
+ const result = new Store()
1280
+ observe(() => {
1281
+ const value = this.get() ? whenTrue : whenFalse
1282
+ result.set(value)
1283
+ })
1284
+ return result
1285
+ }
1286
+
1287
+ /**
1288
+ * Sets the `Store` value to the given argument.
1187
1289
  *
1188
1290
  * When a `Store` is passed in as the value, its value will be copied (subscribing to changes). In
1189
1291
  * case the value is an object, an `Array` or a `Map`, a *reference* to that data structure will
@@ -1193,69 +1295,110 @@ export class Store {
1193
1295
  *
1194
1296
  * If you intent to make a copy instead of a reference, call {@link Store.get} on the origin `Store`.
1195
1297
  *
1196
- *
1298
+ * @returns The `Store` itself, for chaining other methods.
1299
+ *
1197
1300
  * @example
1198
1301
  * ```
1199
1302
  * let store = new Store() // Value is `undefined`
1200
1303
  *
1201
- * store.set('x', 6) // Causes the store to become an object
1202
- * assert(store.get() == {x: 6})
1304
+ * store.set(6)
1305
+ * store.get() // 6
1203
1306
  *
1204
- * store.set('a', 'b', 'c', 'd') // Create parent path as objects
1205
- * assert(store.get() == {x: 6, a: {b: {c: 'd'}}})
1307
+ * store.set({}) // Change value to an empty object
1308
+ * store('a', 'b', 'c').set('d') // Create parent path as objects
1309
+ * store.get() // {x: 6, a: {b: {c: 'd'}}}
1206
1310
  *
1207
1311
  * store.set(42) // Overwrites all of the above
1208
- * assert(store.get() == 42)
1312
+ * store.get() // 42
1209
1313
  *
1210
- * store.set('x', 6) // Throw Error (42 is not a collection)
1314
+ * store('x').set(6) // Throw Error (42 is not a collection)
1211
1315
  * ```
1212
1316
  */
1213
- set(...pathAndValue: any[]): void {
1214
- let newValue = pathAndValue.pop()
1215
- let store = this.makeRef(...pathAndValue)
1216
- store._collection._setIndex(store._idx, newValue, true)
1317
+ set(newValue: any): Store {
1318
+ this._materialize(true)
1319
+ this._collection._setIndex(this._idx, newValue, true)
1217
1320
  runImmediateQueue()
1321
+ return this
1322
+ }
1323
+
1324
+ /** @internal */
1325
+ _materialize(forWriting: boolean): boolean {
1326
+ if (!this._virtual) return true
1327
+ let collection = this._collection
1328
+ let idx = this._idx
1329
+ for(let i=0; i<this._virtual.length; i++) {
1330
+ if (!forWriting && currentScope) {
1331
+ if (collection._addObserver(idx, currentScope)) {
1332
+ currentScope._cleaners.push(this)
1333
+ }
1334
+ }
1335
+ let value = collection.rawGet(idx)
1336
+ if (!(value instanceof ObsCollection)) {
1337
+ // Throw an error if trying to index a primitive type
1338
+ if (value!==undefined) throw new Error(`While resolving ${JSON.stringify(this._virtual)}, found ${JSON.stringify(value)} at index ${i} instead of a collection.`)
1339
+ // For reads, we'll just give up. We might reactively get another shot at this.
1340
+ if (!forWriting) return false
1341
+ // For writes, create a new collection.
1342
+ value = new ObsObject()
1343
+ collection.rawSet(idx, value)
1344
+ collection.emitChange(idx, value, undefined)
1345
+ }
1346
+ collection = value
1347
+ const prop = this._virtual[i]
1348
+ idx = collection._normalizeIndex(prop)
1349
+ }
1350
+ this._collection = collection
1351
+ this._idx = idx
1352
+ delete this._virtual
1353
+ return true
1218
1354
  }
1219
1355
 
1220
1356
  /**
1221
1357
  * Sets the `Store` to the given `mergeValue`, but without deleting any pre-existing
1222
1358
  * items when a collection overwrites a similarly typed collection. This results in
1223
1359
  * a deep merge.
1360
+ *
1361
+ * @returns The `Store` itself, for chaining other methods.
1224
1362
  *
1225
1363
  * @example
1226
1364
  * ```
1227
1365
  * let store = new Store({a: {x: 1}})
1228
1366
  * store.merge({a: {y: 2}, b: 3})
1229
- * assert(store.get() == {a: {x: 1, y: 2}, b: 3})
1367
+ * store.get() // {a: {x: 1, y: 2}, b: 3}
1230
1368
  * ```
1231
1369
  */
1232
- merge(...pathAndValue: any): void {
1233
- let mergeValue = pathAndValue.pop()
1234
- let store = this.makeRef(...pathAndValue)
1235
- store._collection._setIndex(store._idx, mergeValue, false)
1370
+ merge(mergeValue: any): Store {
1371
+ this._materialize(true)
1372
+ this._collection._setIndex(this._idx, mergeValue, false)
1236
1373
  runImmediateQueue()
1374
+ return this
1237
1375
  }
1238
1376
 
1239
1377
  /**
1240
1378
  * 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)
1379
+ *
1380
+ * @returns The `Store` itself, for chaining other methods.
1241
1381
  *
1242
1382
  * @example
1243
1383
  * ```
1244
1384
  * let store = new Store({a: 1, b: 2})
1245
- * store.delete('a')
1246
- * assert(store.get() == {b: 2})
1385
+ * store('a').delete()
1386
+ * store.get() // {b: 2}
1247
1387
  *
1248
1388
  * 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'])
1389
+ * store(1).delete()
1390
+ * store.get() // ['a', undefined, 'c']
1391
+ * store(2).delete()
1392
+ * store.get() // ['a']
1393
+ * store.delete()
1394
+ * store.get() // undefined
1253
1395
  * ```
1254
1396
  */
1255
- delete(...path: any) {
1256
- let store = this.makeRef(...path)
1257
- store._collection._setIndex(store._idx, undefined, true)
1397
+ delete(): Store {
1398
+ this._materialize(true)
1399
+ this._collection._setIndex(this._idx, undefined, true)
1258
1400
  runImmediateQueue()
1401
+ return this
1259
1402
  }
1260
1403
 
1261
1404
  /**
@@ -1263,28 +1406,30 @@ export class Store {
1263
1406
  * If that store path is `undefined`, an Array is created first.
1264
1407
  * The last argument is the value to be added, any earlier arguments indicate the path.
1265
1408
  *
1409
+ * @returns The index at which the item was appended.
1410
+ * @throws TypeError when the store contains a primitive data type.
1411
+ *
1266
1412
  * @example
1267
1413
  * ```
1268
1414
  * let store = new Store()
1269
1415
  * store.push(3) // Creates the array
1270
1416
  * store.push(6)
1271
- * assert(store.get() == [3,6])
1417
+ * store.get() // [3,6]
1272
1418
  *
1273
1419
  * store = new Store({myArray: [1,2]})
1274
- * store.push('myArray', 3)
1275
- * assert(store.get() == {myArray: [1,2,3]})
1420
+ * store('myArray').push(3)
1421
+ * store.get() // {myArray: [1,2,3]}
1276
1422
  * ```
1277
1423
  */
1278
- push(...pathAndValue: any[]): number {
1279
- let newValue = pathAndValue.pop()
1280
- let store = this.makeRef(...pathAndValue)
1424
+ push(newValue: any): number {
1425
+ this._materialize(true)
1281
1426
 
1282
- let obsArray = store._collection.rawGet(store._idx)
1427
+ let obsArray = this._collection.rawGet(this._idx)
1283
1428
  if (obsArray===undefined) {
1284
1429
  obsArray = new ObsArray()
1285
- store._collection._setIndex(store._idx, obsArray, true)
1430
+ this._collection._setIndex(this._idx, obsArray, true)
1286
1431
  } else if (!(obsArray instanceof ObsArray)) {
1287
- throw new Error(`push() is only allowed for an array or undefined (which would become an array)`)
1432
+ throw new TypeError(`push() is only allowed for an array or undefined (which would become an array)`)
1288
1433
  }
1289
1434
 
1290
1435
  let newData = valueToData(newValue)
@@ -1299,74 +1444,16 @@ export class Store {
1299
1444
  * {@link Store.peek} the current value, pass it through `func`, and {@link Store.set} the resulting
1300
1445
  * value.
1301
1446
  * @param func The function transforming the value.
1447
+ * @returns The `Store` itself, for chaining other methods.
1302
1448
  */
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
- }
1326
- }
1327
-
1328
- return store
1329
- }
1330
-
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
1449
+ modify(func: (value: any) => any): Store {
1450
+ this.set(func(this.peek()))
1451
+ return this
1366
1452
  }
1367
1453
 
1368
1454
  /** @internal */
1369
1455
  _observe() {
1456
+ if (!this._materialize(false)) return undefined
1370
1457
  if (currentScope) {
1371
1458
  if (this._collection._addObserver(this._idx, currentScope)) {
1372
1459
  currentScope._cleaners.push(this)
@@ -1379,25 +1466,19 @@ export class Store {
1379
1466
  * Iterate the specified collection (Array, Map or object), running the given code block for each item.
1380
1467
  * When items are added to the collection at some later point, the code block will be ran for them as well.
1381
1468
  * When an item is removed, the {@link Store.clean} handlers left by its code block are executed.
1382
- *
1383
- *
1384
- *
1385
- * @param pathAndFuncs
1469
+ *
1470
+ * @param renderer The function to be called for each item. It receives the item's `Store` object as its only argument.
1471
+ * @param makeSortKey An optional function that, given an items `Store` object, returns a value to be sorted on.
1472
+ * This value can be a number, a string, or an array containing a combination of both. When undefined is returned,
1473
+ * the item is *not* rendered. If `makeSortKey` is not specified, the output will be sorted by `index()`.
1386
1474
  */
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()
1475
+ onEach(renderer: (store: Store) => void, makeSortKey: (store: Store) => any = defaultMakeSortKey): void {
1476
+ if (!currentScope) { // Do this in a new top-level scope
1477
+ _mount(undefined, () => this.onEach(renderer, makeSortKey), SimpleScope)
1478
+ return
1393
1479
  }
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
1480
 
1398
- let store = this.ref(...pathAndFuncs)
1399
-
1400
- let val = store._observe()
1481
+ let val = this._observe()
1401
1482
  if (val instanceof ObsCollection) {
1402
1483
  // Subscribe to changes using the specialized OnEachScope
1403
1484
  let onEachScope = new OnEachScope(currentScope._parentElement, currentScope._lastChild || currentScope._precedingSibling, currentScope._queueOrder+1, val, renderer, makeSortKey)
@@ -1412,6 +1493,31 @@ export class Store {
1412
1493
  }
1413
1494
  }
1414
1495
 
1496
+ /**
1497
+ * Derive a new `Store` from this `Store`, by reactively passing its value
1498
+ * through the specified function.
1499
+ * @param func Your function. It should accept a the input store's value, and return
1500
+ * a result to be reactively set to the output store.
1501
+ * @returns The output `Store`.
1502
+ * @example
1503
+ * ```javascript
1504
+ * const store = new Store(21)
1505
+ * const double = store.derive(v => v*2)
1506
+ * double.get() // 42
1507
+ *
1508
+ * store.set(100)
1509
+ * runQueue() // Or after a setTimeout 0, due to batching
1510
+ * double.get() // 200
1511
+ * ```
1512
+ */
1513
+ derive(func: (value: any) => any): Store {
1514
+ let out = new Store()
1515
+ observe(() => {
1516
+ out.set(func(this.get()))
1517
+ })
1518
+ return out
1519
+ }
1520
+
1415
1521
  /**
1416
1522
  * Applies a filter/map function on each item within the `Store`'s collection,
1417
1523
  * and reactively manages the returned `Map` `Store` to hold any results.
@@ -1419,25 +1525,30 @@ export class Store {
1419
1525
  * @param func - Function that transform the given store into an output value or
1420
1526
  * `undefined` in case this value should be skipped:
1421
1527
  *
1422
- * @returns - A map `Store` with the values returned by `func` and the corresponding
1423
- * keys from the original map, array or object `Store`.
1528
+ * @returns - A array/map/object `Store` with the values returned by `func` and the
1529
+ * corresponding keys from the original map, array or object `Store`.
1424
1530
  *
1425
1531
  * When items disappear from the `Store` or are changed in a way that `func` depends
1426
1532
  * upon, the resulting items are removed from the output `Store` as well. When multiple
1427
1533
  * input items produce the same output keys, this may lead to unexpected results.
1428
1534
  */
1429
1535
  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
- })
1536
+ let out = new Store()
1537
+ observe(() => {
1538
+ let t = this.getType()
1539
+ out.set(t==='array' ? [] : (t==='object' ? {} : new Map()))
1540
+ this.onEach((item: Store) => {
1541
+ let value = func(item)
1542
+ if (value !== undefined) {
1543
+ let key = item.index()
1544
+ const ref = out(key)
1545
+ ref.set(value)
1546
+ clean(() => {
1547
+ ref.delete()
1548
+ })
1549
+ }
1550
+ })
1551
+ })
1441
1552
  return out
1442
1553
  }
1443
1554
 
@@ -1461,24 +1572,26 @@ export class Store {
1461
1572
  let out = new Store(new Map())
1462
1573
  this.onEach((item: Store) => {
1463
1574
  let result = func(item)
1464
- let keys: Array<any>
1575
+ let refs: Array<Store> = []
1465
1576
  if (result.constructor === Object) {
1466
1577
  for(let key in result) {
1467
- out.set(key, result[key])
1578
+ const ref = out(key)
1579
+ ref.set(result[key])
1580
+ refs.push(ref)
1468
1581
  }
1469
- keys = Object.keys(result)
1470
1582
  } else if (result instanceof Map) {
1471
1583
  result.forEach((value: any, key: any) => {
1472
- out.set(key, value)
1584
+ const ref = out(key)
1585
+ ref.set(value)
1586
+ refs.push(ref)
1473
1587
  })
1474
- keys = [...result.keys()]
1475
1588
  } else {
1476
1589
  return
1477
1590
  }
1478
- if (keys.length) {
1591
+ if (refs.length) {
1479
1592
  clean(() => {
1480
- for(let key of keys) {
1481
- out.delete(key)
1593
+ for(let ref of refs) {
1594
+ ref.delete()
1482
1595
  }
1483
1596
  })
1484
1597
  }
@@ -1486,42 +1599,31 @@ export class Store {
1486
1599
  return out
1487
1600
  }
1488
1601
 
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
-
1495
1602
  /**
1496
1603
  * Dump a live view of the `Store` tree as HTML text, `ul` and `li` nodes at
1497
1604
  * the current mount position. Meant for debugging purposes.
1605
+ * @returns The `Store` itself, for chaining other methods.
1498
1606
  */
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
- }
1607
+ dump(): Store {
1608
+ let type = this.getType()
1609
+ if (type === 'array' || type === 'object' || type === 'map') {
1610
+ $({text: `<${type}>`})
1611
+ $('ul', () => {
1612
+ this.onEach((sub: Store) => {
1613
+ $('li:'+JSON.stringify(sub.index())+": ", () => {
1614
+ sub.dump()
1615
+ })
1616
+ })
1617
+ })
1618
+ } else {
1619
+ $({text: JSON.stringify(this.get())})
1620
+ }
1621
+ return this
1515
1622
  }
1516
1623
  }
1517
1624
 
1518
- class DetachedStore extends Store {
1519
- isDetached() { return true }
1520
- }
1521
-
1522
1625
 
1523
1626
 
1524
- let onCreateEnabled = false
1525
1627
  let onDestroyMap: WeakMap<Node, string | Function | true> = new WeakMap()
1526
1628
 
1527
1629
  function destroyWithClass(element: Element, cls: string) {
@@ -1530,92 +1632,23 @@ function destroyWithClass(element: Element, cls: string) {
1530
1632
  }
1531
1633
 
1532
1634
 
1533
- /**
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
- * })
1547
- */
1548
- export function node(tag: string|Element = "", ...rest: any[]) {
1549
- if (!currentScope) throw new ScopeError(true)
1550
-
1551
- let el
1552
- if (tag instanceof Element) {
1553
- el = tag
1635
+ function addLeafNode(deepEl: Element, node: Node) {
1636
+ if (deepEl === (currentScope as Scope)._parentElement) {
1637
+ currentScope!._addNode(node)
1554
1638
  } 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
-
1568
- currentScope._addNode(el)
1569
-
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
1578
- } else {
1579
- scope._update()
1580
- }
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])
1590
- }
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)}`)
1595
- }
1639
+ deepEl.appendChild(node)
1596
1640
  }
1597
1641
  }
1598
1642
 
1599
1643
 
1600
-
1601
- /**
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>"`.
1604
- */
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
- }
1612
- }
1613
-
1614
- function bindInput(el: HTMLInputElement, store: Store) {
1644
+ function applyBinding(_el: Element, _key: string, store: Store) {
1645
+ if (store==null) return
1646
+ if (!(store instanceof Store)) throw new Error(`Unexpect bind-argument: ${JSON.parse(store)}`)
1647
+ const el = _el as HTMLInputElement
1615
1648
  let onStoreChange: (value: any) => void
1616
1649
  let onInputChange: () => void
1617
1650
  let type = el.getAttribute('type')
1618
- let value = store.query({peek: true})
1651
+ let value = store.peek()
1619
1652
  if (type === 'checkbox') {
1620
1653
  if (value === undefined) store.set(el.checked)
1621
1654
  onStoreChange = value => el.checked = value
@@ -1640,98 +1673,254 @@ function bindInput(el: HTMLInputElement, store: Store) {
1640
1673
  clean(() => {
1641
1674
  el.removeEventListener('input', onInputChange)
1642
1675
  })
1643
-
1644
1676
  }
1645
1677
 
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))
1678
+
1679
+ const SPECIAL_PROPS: {[key: string]: (el: Element, value: any) => void} = {
1680
+ create: function(el: Element, value: any) {
1681
+ if (!showCreateTransitions) return
1682
+ if (typeof value === 'function') {
1683
+ value(el)
1684
+ } else {
1685
+ el.classList.add(value);
1686
+ (async function(){
1687
+ await DOM_READ_PHASE;
1688
+ (el as HTMLElement).offsetHeight;
1689
+ await DOM_WRITE_PHASE;
1690
+ el.classList.remove(value)
1691
+ })()
1692
+ }
1693
+ },
1694
+ destroy: function(deepEl: Element, value: any) {
1695
+ onDestroyMap.set(deepEl, value)
1696
+ },
1697
+ html: function(deepEl: Element, value: any) {
1698
+ if (!value) return
1699
+ let tmpParent = document.createElement(deepEl.tagName)
1700
+ tmpParent.innerHTML = ''+value
1701
+ while(tmpParent.firstChild) addLeafNode(deepEl, tmpParent.firstChild)
1702
+ },
1703
+ text: function(deepEl: Element, value: any) {
1704
+ if (value!=null) addLeafNode(deepEl, document.createTextNode(value))
1705
+ },
1706
+ element: function(deepEl: Element, value: any) {
1707
+ if (value==null) return
1708
+ if (!(value instanceof Node)) throw new Error(`Unexpect element-argument: ${JSON.parse(value)}`)
1709
+ addLeafNode(deepEl, value)
1710
+ },
1653
1711
  }
1654
1712
 
1655
1713
 
1714
+
1656
1715
  /**
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*.
1716
+ * Modifies the *parent* DOM element in the current reactive scope, or adds
1717
+ * new DOM elements to it.
1718
+ *
1719
+ * @param args - Arguments that define how to modify/create elements.
1720
+ *
1721
+ * ### String arguments
1722
+ * Create new elements with optional classes and text content:
1723
+ * ```js
1724
+ * $('div.myClass') // <div class="myClass"></div>
1725
+ * $('span.c1.c2:Hello') // <span class="c1 c2">Hello</span>
1726
+ * $('p:Some text') // <p>Some text</p>
1727
+ * $('.my-thing') // <div class="my-thing"></div>
1728
+ * $('div', 'span', 'p.cls') // <div><span<p class="cls"></p></span></div>
1729
+ * $(':Just some text!') // Just some text! (No new element, just a text node)
1730
+ * ```
1731
+ *
1732
+ * ### Object arguments
1733
+ * Set properties, attributes, events and special features:
1734
+ * ```js
1735
+ * // Classes (dot prefix)
1736
+ * $('div', {'.active': true}) // Add class
1737
+ * $('div', {'.hidden': false}) // Remove (or don't add) class
1738
+ * $('div', {'.selected': myStore}) // Reactively add/remove class
1739
+ *
1740
+ * // Styles (dollar prefixed and camel-cased CSS properties)
1741
+ * $('div', {$color: 'red'}) // style.color = 'red'
1742
+ * $('div', {$marginTop: '10px'}) // style.marginTop = '10px'
1743
+ * $('div', {$color: myColorStore}) // Reactively change color
1744
+ *
1745
+ * // Events (function values)
1746
+ * $('button', {click: () => alert()}) // Add click handler
1747
+ *
1748
+ * // Properties (boolean values, `selectedIndex`, `value`)
1749
+ * $('input', {disabled: true}) // el.disabled = true
1750
+ * $('input', {value: 'test'}) // el.value = 'test'
1751
+ * $('select', {selectedIndex: 2}) // el.selectedIndex = 2
1752
+ *
1753
+ * // Transitions
1754
+ * $('div', {create: 'fade-in'}) // Add class on create
1755
+ * $('div', {create: el => {...}}) // Run function on create
1756
+ * $('div', {destroy: 'fade-out'}) // Add class before remove
1757
+ * $('div', {destroy: el => {...}}) // Run function before remove
1758
+ *
1759
+ * // Content
1760
+ * $('div', {html: '<b>Bold</b>'}) // Set innerHTML
1761
+ * $('div', {text: 'Plain text'}) // Add text node
1762
+ * const myElement = document.createElement('video')
1763
+ * $('div', {element: myElement}) // Add existing DOM element
1693
1764
  *
1694
- * @example
1765
+ * // Regular attributes (everything else)
1766
+ * $('div', {title: 'Info'}) // el.setAttribute('title', 'info')
1767
+ * ```
1768
+ *
1769
+ * When a `Store` is passed as a value, a seperate observe-scope will
1770
+ * be created for it, such that when the `Store` changes, only *that*
1771
+ * UI property will need to be updated.
1772
+ * So in the following example, when `colorStore` changes, only the
1773
+ * `color` CSS property will be updated.
1774
+ * ```js
1775
+ * $('div', {
1776
+ * '.active': activeStore, // Reactive class
1777
+ * $color: colorStore, // Reactive style
1778
+ * text: textStore // Reactive text
1779
+ * })
1780
+ * ```
1781
+ *
1782
+ * ### Two-way input binding
1783
+ * Set the initial value of an <input> <textarea> or <select> to that
1784
+ * of a `Store`, and then start reflecting user changes to the former
1785
+ * in the latter.
1786
+ * ```js
1787
+ * $('input', {bind: myStore}) // Binds input.value
1695
1788
  * ```
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
- * })
1789
+ * This is a special case, as changes to the `Store` will *not* be
1790
+ * reflected in the UI.
1791
+ *
1792
+ * ### Function arguments
1793
+ * Create child scopes that re-run on observed `Store` changes:
1794
+ * ```js
1795
+ * $('div', () => {
1796
+ * $(myStore.get() ? 'span' : 'p') // Reactive element type
1715
1797
  * })
1716
1798
  * ```
1799
+ * When *only* a function is given, `$` behaves exactly like {@link Store.observe},
1800
+ * except that it will only work when we're inside a `mount`.
1801
+ *
1802
+ * @throws {ScopeError} If called outside an observable scope.
1803
+ * @throws {Error} If invalid arguments are provided.
1717
1804
  */
1718
- export function prop(name: string, value: any): void
1719
- export function prop(props: object): void
1720
1805
 
1721
- export function prop(name: any, value: any = undefined) {
1806
+ export function $(...args: (string | (() => void) | false | null | undefined | {[key: string]: any})[]) {
1722
1807
  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])
1808
+
1809
+ let deepEl = currentScope._parentElement
1810
+
1811
+ for(let arg of args) {
1812
+ if (arg == null || arg === false) continue
1813
+ if (typeof arg === 'string') {
1814
+ let text, classes
1815
+ const textPos = arg.indexOf(':')
1816
+ if (textPos >= 0) {
1817
+ text = arg.substring(textPos+1)
1818
+ if (textPos === 0) { // Just a string to add as text, no new node
1819
+ addLeafNode(deepEl, document.createTextNode(text))
1820
+ continue
1821
+ }
1822
+ arg = arg.substring(0,textPos)
1823
+ }
1824
+ const classPos = arg.indexOf('.')
1825
+ if (classPos >= 0) {
1826
+ classes = arg.substring(classPos+1).replaceAll('.', ' ')
1827
+ arg = arg.substring(0, classPos)
1828
+ }
1829
+ if (arg.indexOf(' ') >= 0) throw new Error(`Tag '${arg}' cannot contain space`)
1830
+ const el = document.createElement(arg || 'div')
1831
+ if (classes) el.className = classes
1832
+ if (text) el.textContent = text
1833
+ addLeafNode(deepEl, el)
1834
+ deepEl = el
1726
1835
  }
1727
- } else {
1728
- applyProp(currentScope._parentElement, name, value)
1836
+ else if (typeof arg === 'object') {
1837
+ if (arg.constructor !== Object) throw new Error(`Unexpected argument: ${arg}`)
1838
+ for(const key in arg) {
1839
+ const val = arg[key]
1840
+ if (key === 'bind') { // Special case, as for this prop we *don't* want to resolve the Store to a value first.
1841
+ applyBinding(deepEl, key, val)
1842
+ } else if (val instanceof Store) {
1843
+ let childScope = new SetArgScope(deepEl, deepEl.lastChild as Node, currentScope!._queueOrder+1, key, val)
1844
+ childScope._install()
1845
+ } else {
1846
+ applyArg(deepEl, key, val)
1847
+ }
1848
+ }
1849
+ } else if (typeof arg === 'function') {
1850
+ if (deepEl === currentScope._parentElement) { // do what observe does
1851
+ _mount(undefined, args[0] as any, SimpleScope)
1852
+ } else { // new scope for a new node without any scope attached yet
1853
+ let childScope = new SimpleScope(deepEl, deepEl.lastChild as Node, currentScope._queueOrder+1, arg)
1854
+ childScope._install()
1855
+ }
1856
+ } else {
1857
+ throw new Error(`Unexpected argument: ${JSON.stringify(arg)}`)
1858
+ }
1859
+ }
1860
+ }
1861
+
1862
+
1863
+ function applyArg(deepEl: Element, key: string, value: any) {
1864
+ if (key[0] === '.') { // CSS class(es)
1865
+ const classes = key.substring(1).split('.')
1866
+ if (value) deepEl.classList.add(...classes)
1867
+ else deepEl.classList.remove(...classes)
1868
+ } else if (key[0] === '$') { // Style
1869
+ const name = key.substring(1);
1870
+ if (value==null || value===false) (deepEl as any).style[name] = ''
1871
+ else (deepEl as any).style[name] = ''+value
1872
+ } else if (key in SPECIAL_PROPS) { // Special property
1873
+ SPECIAL_PROPS[key](deepEl, value)
1874
+ } else if (typeof value === 'function') { // Event listener
1875
+ deepEl.addEventListener(key, value)
1876
+ clean(() => deepEl.removeEventListener(key, value))
1877
+ } else if (value===true || value===false || key==='value' || key==='selectedIndex') { // DOM property
1878
+ (deepEl as any)[key] = value
1879
+ } else { // HTML attribute
1880
+ deepEl.setAttribute(key, value)
1729
1881
  }
1730
1882
  }
1883
+
1884
+ function defaultOnError(error: Error) {
1885
+ console.error('Error while in Aberdeen render:', error)
1886
+ return true
1887
+ }
1888
+ let onError: (error: Error) => boolean | undefined = defaultOnError
1889
+
1890
+ /**
1891
+ * Set a custome error handling function, thast is called when an error occurs during rendering
1892
+ * while in a reactive scope. The default implementation logs the error to the console, and then
1893
+ * just returns `true`, which causes an 'Error' message to be displayed in the UI. When this function
1894
+ * returns `false`, the error is suppressed. This mechanism exists because rendering errors can occur
1895
+ * at any time, not just synchronous when making a call to Aberdeen, thus normal exception handling
1896
+ * is not always possible.
1897
+ *
1898
+ * @param handler The handler function, getting an `Error` as its argument, and returning `false`
1899
+ * if it does *not* want an error message to be added to the DOM.
1900
+ * When `handler is `undefined`, the default error handling will be reinstated.
1901
+ *
1902
+ * @example
1903
+ * ```javascript
1904
+ * //
1905
+ * setErrorHandler(error => {
1906
+ * // Tell our developers about the problem.
1907
+ * fancyErrorLogger(error)
1908
+ * // Add custom error message to the DOM.
1909
+ * try {
1910
+ * $('.error:Sorry, something went wrong!')
1911
+ * } catch() {} // In case there is no parent element.
1912
+ * // Don't add default error message to the DOM.
1913
+ * return false
1914
+ * })
1915
+ * ```
1916
+ */
1917
+ export function setErrorHandler(handler?: (error: Error) => boolean | undefined) {
1918
+ onError = handler || defaultOnError
1919
+ }
1731
1920
 
1732
1921
 
1733
1922
  /**
1734
- * Return the browser Element that `node()`s would be rendered to at this point.
1923
+ * Return the browser Element that nodes would be rendered to at this point.
1735
1924
  * NOTE: Manually changing the DOM is not recommended in most cases. There is
1736
1925
  * usually a better, declarative way. Although there are no hard guarantees on
1737
1926
  * how your changes interact with Aberdeen, in most cases results will not be
@@ -1795,10 +1984,10 @@ export function immediateObserve(func: () => void): number | undefined {
1795
1984
 
1796
1985
 
1797
1986
  /**
1798
- * Like {@link Store.observe}, but allow the function to create DOM elements using {@link Store.node}.
1987
+ * Reactively run the function, adding any DOM-elements created using {@link $} to the given parent element.
1799
1988
 
1800
1989
  * @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`.
1990
+ * @param parentElement - A DOM element that will be used as the parent element for calls to `$`.
1802
1991
  * @returns The mount id (usable for `unmount`) if this is a top-level mount.
1803
1992
  *
1804
1993
  * @example
@@ -1807,7 +1996,7 @@ export function immediateObserve(func: () => void): number | undefined {
1807
1996
  * setInterval(() => store.modify(v => v+1), 1000)
1808
1997
  *
1809
1998
  * mount(document.body, () => {
1810
- * node('h2', `${store.get()} seconds have passed`)
1999
+ * $(`h2:${store.get()} seconds have passed`)
1811
2000
  * })
1812
2001
  * ```
1813
2002
  *
@@ -1817,30 +2006,37 @@ export function immediateObserve(func: () => void): number | undefined {
1817
2006
  * let colors = new Store(new Map())
1818
2007
  *
1819
2008
  * 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
- * })
2009
+ * // This function will never rerun (as it does not read any `Store`s)
2010
+ * $('button:<<', {click: () => selected.modify(n => n-1)})
2011
+ * $('button:>>', {click: () => selected.modify(n => n+1)})
2012
+ *
2013
+ * observe(() => {
2014
+ * // This will rerun whenever `selected` changes, recreating the <h2> and <input>.
2015
+ * $('h2', {text: '#' + selected.get()})
2016
+ * $('input', {type: 'color', value: '#ffffff' bind: colors(selected.get())})
2017
+ * })
2018
+ *
2019
+ * observe(() => {
2020
+ * // This function will rerun when `selected` or the selected color changes.
2021
+ * // It will change the <body> background-color.
2022
+ * $({$backgroundColor: colors.get(selected.get()) || 'white'})
2023
+ * })
1835
2024
  * })
1836
2025
  * ```
1837
2026
  */
1838
2027
  export function mount(parentElement: Element, func: () => void) {
2028
+ for(let scope of topScopes.values()) {
2029
+ if (parentElement === scope._parentElement) {
2030
+ throw new Error("Only a single mount per parent element")
2031
+ }
2032
+ }
2033
+
1839
2034
  return _mount(parentElement, func, SimpleScope)
1840
2035
  }
1841
2036
 
1842
2037
  let maxTopScopeId = 0
1843
2038
  const topScopes: Map<number, SimpleScope> = new Map()
2039
+
1844
2040
  function _mount(parentElement: Element | undefined, func: () => void, MountScope: typeof SimpleScope): number | undefined {
1845
2041
  let scope
1846
2042
  if (parentElement || !currentScope) {
@@ -1875,6 +2071,7 @@ export function unmount(id?: number) {
1875
2071
  } else {
1876
2072
  let scope = topScopes.get(id)
1877
2073
  if (!scope) throw new Error("No such mount "+id)
2074
+ topScopes.delete(id)
1878
2075
  scope._remove()
1879
2076
  }
1880
2077
  }
@@ -1909,59 +2106,20 @@ export function peek<T>(func: () => T): T {
1909
2106
  } finally {
1910
2107
  currentScope = savedScope
1911
2108
  }
1912
- }
1913
-
2109
+ }
1914
2110
 
1915
2111
  /*
1916
2112
  * Helper functions
1917
2113
  */
1918
2114
 
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)
1927
- }
1928
- }
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
- }
1955
- }
1956
-
1957
2115
  function valueToData(value: any) {
1958
- if (typeof value !== "object" || !value) {
1959
- // Simple data types
1960
- return value
1961
- } else if (value instanceof Store) {
2116
+ if (value instanceof Store) {
1962
2117
  // When a Store is passed pointing at a collection, a reference
1963
2118
  // is made to that collection.
1964
2119
  return value._observe()
2120
+ } else if (typeof value !== "object" || !value) {
2121
+ // Simple data types
2122
+ return value
1965
2123
  } else if (value instanceof Map) {
1966
2124
  let result = new ObsMap()
1967
2125
  value.forEach((v,k) => {
@@ -1997,14 +2155,15 @@ function defaultMakeSortKey(store: Store) {
1997
2155
 
1998
2156
  /* c8 ignore start */
1999
2157
  function internalError(code: number) {
2000
- let error = new Error("Aberdeen internal error "+code)
2001
- setTimeout(() => { throw error }, 0)
2158
+ throw new Error("Aberdeen internal error "+code)
2002
2159
  }
2003
2160
  /* c8 ignore end */
2004
2161
 
2005
- function handleError(e: any) {
2006
- // Throw the error async, so the rest of the rendering can continue
2007
- setTimeout(() => {throw e}, 0)
2162
+ function handleError(e: any, showMessage: boolean) {
2163
+ try {
2164
+ if (onError(e) === false) showMessage = false
2165
+ } catch {}
2166
+ if (showMessage && currentScope?._parentElement) $('.aberdeen-error:Error')
2008
2167
  }
2009
2168
 
2010
2169
  class ScopeError extends Error {
@@ -2024,14 +2183,11 @@ export function withEmitHandler(handler: (this: ObsCollection, index: any, newDa
2024
2183
  }
2025
2184
  }
2026
2185
 
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
2186
  // @ts-ignore
2036
2187
  // c8 ignore next
2037
2188
  if (!String.prototype.replaceAll) String.prototype.replaceAll = function(from, to) { return this.split(from).join(to) }
2189
+ declare global {
2190
+ interface String {
2191
+ replaceAll(from: string, to: string): string;
2192
+ }
2193
+ }