snapse 0.0.0-stage → 0.9.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1169 -2
  3. package/dist/DOM/domFunctions.d.ts +12 -0
  4. package/dist/DOM/elements/DOMElements.d.ts +141 -0
  5. package/dist/DOM/elements/SVGElements.d.ts +65 -0
  6. package/dist/DOM/elements/elementAttributeTypes.d.ts +100 -0
  7. package/dist/DOM/nodeControllers/ComponentController.d.ts +10 -0
  8. package/dist/DOM/nodeControllers/ElementController.d.ts +14 -0
  9. package/dist/DOM/nodeControllers/NodeController.d.ts +81 -0
  10. package/dist/DOM/nodeControllers/RootController.d.ts +12 -0
  11. package/dist/DOM/nodeControllers/SVGElementController.d.ts +4 -0
  12. package/dist/DOM/nodeControllers/StateController.d.ts +43 -0
  13. package/dist/DOM/server/serialize.d.ts +2 -0
  14. package/dist/DOM/server/serverNodes.d.ts +41 -0
  15. package/dist/DOM/types.d.ts +84 -0
  16. package/dist/DOM/util/RenderError.d.ts +10 -0
  17. package/dist/DOM/util/childShape.d.ts +7 -0
  18. package/dist/DOM/util/deferredNodes.d.ts +7 -0
  19. package/dist/DOM/util/domNode.d.ts +2 -0
  20. package/dist/DOM/util/moveBeforeSupport.d.ts +1 -0
  21. package/dist/DOM/util/nodeCount.d.ts +2 -0
  22. package/dist/DOM/util/util.d.ts +2 -0
  23. package/dist/DOM.d.ts +4 -0
  24. package/dist/SVG.d.ts +3 -0
  25. package/dist/common/computeMutation.d.ts +16 -0
  26. package/dist/common/dev.d.ts +1 -0
  27. package/dist/common/shorthands.d.ts +1 -0
  28. package/dist/common/undoUtils.d.ts +1 -0
  29. package/dist/common/util.d.ts +4 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/lint/index.d.ts +7 -0
  32. package/dist/lint/index.js +8 -0
  33. package/dist/lint/noBooleanStateArgument.d.ts +2 -0
  34. package/dist/lint/noBooleanStateArgument.js +43 -0
  35. package/dist/lint/noStateTruthiness.d.ts +2 -0
  36. package/dist/lint/noStateTruthiness.js +92 -0
  37. package/dist/lint/stateTypes.d.ts +4 -0
  38. package/dist/lint/stateTypes.js +14 -0
  39. package/dist/server.d.ts +2 -0
  40. package/dist/snapse.DOM.cjs +1 -0
  41. package/dist/snapse.DOM.development.cjs +115 -0
  42. package/dist/snapse.DOM.development.js +1 -0
  43. package/dist/snapse.SVG.cjs +1 -0
  44. package/dist/snapse.SVG.development.cjs +66 -0
  45. package/dist/snapse.SVG.development.js +1 -0
  46. package/dist/snapse.SVG.js +1 -0
  47. package/dist/snapse.cjs +1 -0
  48. package/dist/snapse.development.cjs +13 -0
  49. package/dist/snapse.development.js +1 -0
  50. package/dist/snapse.js +75 -0
  51. package/dist/snapse.server.cjs +1 -0
  52. package/dist/snapse.server.development.cjs +3 -0
  53. package/dist/snapse.server.development.js +1 -0
  54. package/dist/snapseCore-Bc8ZDYHa.cjs +1 -0
  55. package/dist/snapseCore-DpadpXgD.js +3002 -0
  56. package/dist/snapseCore-Xfnvf03Z.cjs +4135 -0
  57. package/dist/state/common/refCountAndType.d.ts +8 -0
  58. package/dist/state/common/stateProperty.d.ts +1 -0
  59. package/dist/state/compute.d.ts +2 -0
  60. package/dist/state/state.d.ts +39 -0
  61. package/dist/state/stateInternals/LightState.d.ts +20 -0
  62. package/dist/state/stateInternals/internalFunctions.d.ts +13 -0
  63. package/dist/state/stateInternals/notify.d.ts +7 -0
  64. package/dist/state/stateInternals/poison.d.ts +4 -0
  65. package/dist/state/stateInternals/stateUpdate.d.ts +9 -0
  66. package/dist/state/subscribeChanges.d.ts +2 -0
  67. package/dist/state/transforms/CallbackTransform.d.ts +13 -0
  68. package/dist/state/transforms/ChainFunctionTransform.d.ts +8 -0
  69. package/dist/state/transforms/ChangeAggregateTransform.d.ts +20 -0
  70. package/dist/state/transforms/ChangeCallbackTransform.d.ts +5 -0
  71. package/dist/state/transforms/ComputeTransform.d.ts +4 -0
  72. package/dist/state/transforms/DOMRootTransform.d.ts +8 -0
  73. package/dist/state/transforms/FunctionTransform.d.ts +5 -0
  74. package/dist/state/transforms/IsTransform.d.ts +25 -0
  75. package/dist/state/transforms/LightStateFunctionTransform.d.ts +3 -0
  76. package/dist/state/transforms/MapArrayTransform.d.ts +10 -0
  77. package/dist/state/transforms/MapValueTransform.d.ts +3 -0
  78. package/dist/state/transforms/OptTransform.d.ts +10 -0
  79. package/dist/state/transforms/PromiseTransform.d.ts +6 -0
  80. package/dist/state/transforms/SubscribeTransform.d.ts +13 -0
  81. package/dist/state/transforms/Transform.d.ts +35 -0
  82. package/dist/state/transforms/transforms.d.ts +30 -0
  83. package/dist/state/types/stateTypes.d.ts +175 -0
  84. package/dist/state/types/types.d.ts +35 -0
  85. package/dist/undo/undo.d.ts +39 -0
  86. package/package.json +141 -4
package/README.md CHANGED
@@ -1,3 +1,1170 @@
1
- # Temporary Holding Version
1
+ <!-- Generated from docs/README.template.md by scripts/buildReadme.mjs. Do not edit README.md directly;
2
+ edit the template or the example sources and run `npm run build:readme`. -->
3
+ # Snapse
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ Snapse is a JavaScript/TypeScript library for creating interactive web pages and applications. It provides state management with a tightly integrated store, and high level components based on DOM elements.
6
+
7
+ Snapse ties chain-reactive states into components to create atomic reactivity, while offering a straightforward development experience that scales from the simplest web page to the most advanced application.
8
+
9
+ Snapse is a very small library. But its goal is to do everything that large web frameworks do - just more simply by leveraging standard JavaScript functionality.
10
+
11
+ ## Compatibility
12
+
13
+ Snapse follows the specifications for ECMAScript 2021 strict mode. It does not modify any global objects or functions. Snapse adheres to the W3C defined behaviour for DOM elements, but extends it - most obviously by supporting the insertion of stateful content. It does not insert 'magic content' into the DOM, and you are free to directly modify the DOM if you so choose - with the obvious limitation that anything Snapse is delegated to update may remove elements and content as the corresponding state changes.
14
+
15
+ Snapse is not a framework - it is a library designed to co-exist with any other tools or libraries that support JavaScript and web standards.
16
+
17
+ ### Import and require
18
+
19
+ Both ESM and CommonJS imports are supported, as well as loading snapse.js directly in a web page. In DEV mode, Snapse provides source maps and throws helpful error messages.
20
+
21
+ ### TypeScript
22
+
23
+ Snapse preserves TypeScript types, and all functions including DOM element factory functions are fully typed, requiring a minimum of explicit typing.
24
+
25
+ ## Usage
26
+
27
+ Create a Vite project, then add Snapse:
28
+
29
+ ```sh
30
+ npm create vite@latest my-app -- --template vanilla-ts
31
+ cd my-app
32
+ npm install snapse
33
+ ```
34
+
35
+ Replace the contents of `src/main.ts`:
36
+
37
+ ```typescript
38
+ import { SnapseRoot, state } from 'snapse'
39
+ import { button } from 'snapse/DOM'
40
+
41
+ const s = state({ count: 0 })
42
+
43
+ SnapseRoot(button({ click: () => s.count++ }, 'Clicked ', s.count, ' times'))
44
+ ```
45
+
46
+ Start the dev server with `npm run dev`.
47
+
48
+ ### Without a build step
49
+
50
+ Snapse can also be loaded directly in a web page, as a single JavaScript module that includes all elements. In this module, the SVG elements whose names clash with HTML elements are named `svg_a`, `svg_script`, `svg_style` and `svg_title`:
51
+
52
+ ```html
53
+ <!doctype html>
54
+ <script type="module">
55
+ import { SnapseRoot, button, state } from 'https://cdn.jsdelivr.net/npm/snapse/dist/snapse.js'
56
+
57
+ const s = state({ count: 0 })
58
+
59
+ SnapseRoot(button({ click: () => s.count++ }, 'Clicked ', s.count, ' times'))
60
+ </script>
61
+ ```
62
+
63
+ ### Lint rule
64
+
65
+ A State is an object, so `if (s.done)` or `s.done && …` is always true, whatever the value. TypeScript cannot see this, but the `snapse/lint` ESLint rule can. It needs [typescript-eslint](https://typescript-eslint.io) with type information:
66
+
67
+ ```sh
68
+ npm install --save-dev eslint typescript-eslint
69
+ ```
70
+
71
+ ```js
72
+ // eslint.config.js
73
+ import snapse from 'snapse/lint'
74
+
75
+ export default tseslint.config(
76
+ {
77
+ ...
78
+ plugins: { snapse },
79
+ rules: {
80
+ 'snapse/no-state-truthiness': 'error',
81
+ 'snapse/no-boolean-state-argument': 'warn',
82
+ },
83
+ },
84
+ )
85
+ ```
86
+
87
+ To test the value of a boolean or number State, use `+s.done`.
88
+
89
+ The `no-boolean-state-argument` rule warns when a boolean State is passed where a plain `boolean` is expected, since the receiving code will most likely test it: pass `!!+s.done` instead.
90
+
91
+ ## State basics
92
+
93
+ The basis of Snapse is State objects. These tie together with Components and DOM elements.
94
+
95
+ ### state: define a primary State
96
+
97
+ ```typescript
98
+ function state<T>(value: T, name?: string | symbol): State<T>
99
+
100
+ ```
101
+
102
+ The value can be any JS data type. One or more literal objects that group state is the suggested way to set up state for a Component, but individual State values can be freely created, extracted, recombined and passed around to functions and components.
103
+
104
+ Name is optional and can be a string or a Symbol. If specified, and the State is defined inside a Component, descendants of the Component can access it.
105
+
106
+ Plain object properties and array items of a State also automatically become State. In the example below, `s.toBuy[0]` is a State representing the string 'milk'.
107
+
108
+ ```typescript
109
+ const s = state({
110
+ listName: 'Groceries',
111
+ toBuy: ['milk', 'tinfoil'],
112
+ alreadyBought: ['jam', 'elephant'],
113
+ locked: false,
114
+ sortFunction: (a: string, b: string) => a.localeCompare(b),
115
+ }, 'shoppingListState')
116
+ ```
117
+
118
+ ### What should be in a State?
119
+
120
+ Snapse States are primarily intended for states that, when updated, will trigger changes in the DOM. While you can put anything you like in a State, and indeed it can be useful to do so, there are many cases where stateful variables are better kept outside of Snapse State.
121
+
122
+ For instance, suppose that your app creates entities with a unique id. You need a counter to keep track of the first available id - but updating that counter probably should not lead to visible changes in the DOM. In this case, putting the id in a State most likely serves no purpose and will only complicate your code.
123
+
124
+ ### Function States: create chained (computed) States
125
+
126
+ Function States create chained (computed) States
127
+
128
+ Turning a function into a State and calling that function will in turn produce a new, _chained_ State. The chained State will be updated to reflect changes to any State used in its calculation, or to the function itself.
129
+
130
+ However, Snapse does not update chained States until the result is needed somewhere. This happens when the State, or any other State that depends on it, is inserted in the DOM, or when `subscribe` is called on the State or any State depending on it. This means that it is safe to create all the chained States you ever need in your application, and let Snapse keep track of what needs to be calculated.
131
+
132
+ ```typescript
133
+ const s = state({
134
+ width: 4,
135
+ area: (height: number): number => s.width * height,
136
+ })
137
+
138
+ // Calling the function state produces a chained state that tracks width.
139
+ // It is not recalculated until it is rendered or subscribed to.
140
+ const area = s.area(7)
141
+ ```
142
+
143
+ ### Promise States: update if the promise resolves
144
+
145
+ Creating a State with a promise, such as an async function, will result in a State that has the value `undefined` until the promise resolves. If the promise is rejected, the State will always remain undefined.
146
+
147
+ ```typescript
148
+ const s = state({ user: fetchUser() })
149
+ ```
150
+
151
+ ### Custom objects
152
+
153
+ Custom objects are opaque to State. Internal changes to a custom object, even for publicly gettable and settable properties, will not cause reactive updates. Reactive updates will only be triggered if the custom object itself is replaced.
154
+
155
+ ```typescript
156
+ // The root reference can't be reassigned in place, so a state to be replaced wholesale
157
+ // is held as a property of a root state instead of being a root state itself.
158
+ const s = state({ v: { selection: new Set<string>(['a']) } })
159
+ const hasB = compute(() => s.v.selection.has('b'))
160
+
161
+ s.v.selection.add('b') // Does not update hasB
162
+ s.v = { selection: new Set(['b', 'c']) } as unknown as typeof s.v // Updates hasB
163
+ ```
164
+
165
+ ## State functions
166
+
167
+ Snapse has a number of functions to handle States. Most code will not use these functions, with the exception of `compute`.
168
+
169
+ ### compute: define computed (chained) States
170
+
171
+ ```typescript
172
+ function compute<T>(fn: () => T): State<T>
173
+
174
+ ```
175
+
176
+ The compute method is a convenient way to create a chained State by an anonymous function.
177
+
178
+ ```typescript
179
+ s.allItems = compute(() => [...s.toBuy, ...s.alreadyBought])
180
+ ```
181
+
182
+ ### findState: find a named State in an ancestor component
183
+
184
+ ```typescript
185
+ function findState<T>(name: string | symbol, defaultValue?: State<T>): State<T>
186
+
187
+ ```
188
+
189
+ Use `findState` inside a component to retrieve a named State from the closest ancestor component that defines it. Note that by using a Symbol, you can limit access to components that know the value of that Symbol. If no State is found, `defaultValue` is returned if given. Otherwise, an Error is thrown. The application is responsible for ensuring that States retrieved using `findState` have the expected format.
190
+
191
+ ```typescript
192
+ const shoppingListState = findState<ShoppingListType>('shoppingListState')
193
+ ```
194
+
195
+ ### peek: get the raw value of a State
196
+
197
+ ```typescript
198
+ function peek<T>(value: T | State<T>): T
199
+
200
+ ```
201
+
202
+ ⚠️ **Note on Boolean Logic:** Because Snapse States are JavaScript objects under the hood, they are always truthy. `!s.flag` will always evaluate to `false`.
203
+
204
+ In most cases, you can use State objects transparently, because JavaScript automatically coerces them into primitives during string manipulation, math operations, or native method calls.
205
+
206
+ There are a few exceptions. If using the strict equality comparison (`===`) on a State, the object State itself will be compared for referential equality. This is always false, unless comparing the State to another reference to itself. It is recommended to cast the State explicitly to its type, eg `String(s.str) === 'approved'`. However, note that `Boolean(s.flag)` is always truthy - here, it is recommended to use `(!!+s.flag)`.
207
+
208
+ Standard array functions that take a boolean returning callback function (`every`, `filter`, `find`, `findIndex`, `findLast`, `findLastIndex` and `some`) automatically coerce a State value to boolean.
209
+
210
+ Using loose equality comparison (`==`) may or may not work depending on what types are being compared, and is discouraged.
211
+
212
+ The logical not operator: `!s.flag` is always `false`. Use `!+s.flag` instead.
213
+
214
+ Using `typeof` on a State will always return `function`. Use `typeof peek(s.value)`.
215
+
216
+ ```typescript
217
+ if (typeof peek(s.sortFunction) === 'function') doSomething()
218
+ ```
219
+
220
+ *Avoid using `peek` unless necessary, and then only use it locally. Passing around or performing calculations with raw values from your States will probably result in more complex code - let Snapse handle your stateful logic. Many applications never need to use `peek` at all.*
221
+
222
+ Most imperative patterns have a direct reactive form:
223
+
224
+ * `peek(s.n) + 1` → `s.n + 1` (arithmetic, string and relational `<` / `>` comparisons coerce the State)
225
+ * `compute(() => peek(a) + peek(b))` → `compute(() => a + b)` (reference the States directly)
226
+ * `compute(() if (peek(a.flag)) return b)` → `a.flag.opt(b)`
227
+ * `if (peek(s.flag))` → `if (+s.flag)` (`+` coerces to integer, which is safe for booleans)
228
+ * `if (!peek(s.flag))` → `if (!+s.flag)`
229
+
230
+ ### isState: check if something is a State
231
+
232
+ ```typescript
233
+ function isState(value: any): boolean
234
+
235
+ ```
236
+
237
+ You rarely need this in application code. Snapse usually handles State and raw values transparently, and you can use `peek()` when you need to guarantee a raw value.
238
+
239
+ ```typescript
240
+ if (isState(propA)) console.log('Received propA as a State')
241
+ ```
242
+
243
+ ### flush: synchronously apply State changes
244
+
245
+ ```typescript
246
+ function flush(state?: State<any>): void
247
+
248
+ ```
249
+
250
+ Snapse processes State and DOM updates asynchronously. Use `flush()` to apply pending changes immediately. This is primarily intended for synchronous unit testing; avoid using it in application code.
251
+
252
+ ```typescript
253
+ s.toBuy.push('snakes')
254
+ flush()
255
+ expect(String(s.allItems)).toBe('milk,tinfoil,snakes,jam,elephant')
256
+ ```
257
+
258
+ One exception is when performing a view transition: in this case, call `flush()` at the end of your `startViewTransition` callback to update the DOM synchronously.
259
+
260
+ ```typescript
261
+ startViewTransition(() => {
262
+ s.locked.set(true)
263
+ flush()
264
+ })
265
+ ```
266
+
267
+ ### subscribe: get notifications for State changes
268
+
269
+ ```typescript
270
+ type SubscribeOptions = { deep?: boolean }
271
+
272
+ function subscribe<T>(
273
+ state: State<T>,
274
+ callback?: (value: State<T>) => void,
275
+ options?: SubscribeOptions,
276
+ ): () => void
277
+
278
+ ```
279
+
280
+ Avoid setting other States in the callback, as that can result in unexpected update chains or even infinite loops. Most applications should not need to use `subscribe`.
281
+
282
+ Passing the `deep` option will make the callback fire also for updates to descendants of the State, through any plain objects and/or array children.
283
+
284
+ Calling the resulting unsubscribe function ends the subscription. If `subscribe` is called in a Component, it will be automatically unsubscribed when the Component is removed from the DOM.
285
+
286
+ ```typescript
287
+ subscribe(s.allItems, () => {
288
+ localStorage.setItem('shoppingListItems', JSON.stringify(s.allItems))
289
+ })
290
+ ```
291
+
292
+ ## Working with State
293
+
294
+ State variables can, with few exceptions (see `peek`), be used just like the values they contain.
295
+
296
+ ### Assignment
297
+
298
+ Primary States can be reassigned to any value:
299
+
300
+ `s.myState = null`
301
+
302
+ Alternately, a setter function can be used for primitive State values and arrays (but not for object States).
303
+
304
+ `s.myState.set(null)`
305
+ This must also be used to modify a standalone or destructured State object, to avoid overwriting your reference:
306
+ `someState.set(null)`
307
+
308
+ ### Object literal assignment
309
+
310
+ Assigning a non-state value to an object literal will create a new state.
311
+
312
+ ```typescript
313
+ const s = state({
314
+ dogs: 17,
315
+ })
316
+ s.cats = 19 // s.cats is now also a State
317
+ ```
318
+
319
+ Assigning an object literal key to an existing state creates a reference.
320
+
321
+ ```typescript
322
+ const s = state({
323
+ productIds: [37, 42, 55],
324
+ })
325
+ s.selectedProduct = s.productIds[2] // Create a reference to a state
326
+ ```
327
+
328
+ To replace a reference to another state, the existing reference must first be explicitly deleted.
329
+
330
+ ```typescript
331
+ delete s.selectedProduct
332
+ s.selectedProduct = s.productIds[1]
333
+ ```
334
+
335
+ Assigning an object literal State to another object literal State will result in an upsert operation: existing State values will be updated (not replaced), and any new values will become new States.
336
+
337
+ ```typescript
338
+ const s = state({
339
+ p: {
340
+ priceBeforeTax: 5,
341
+ taxRate: 0.2,
342
+ },
343
+ })
344
+ s.p.priceAfterTax = compute(() => s.p.priceBeforeTax * (1 + s.p.taxRate))
345
+ // Assigning an object literal can only be done on a property of another object literal.
346
+ // It upserts: the existing values are updated in place (not replaced), so priceAfterTax
347
+ // stays connected and recomputes. New values are added.
348
+ s.p = { priceBeforeTax: 8, taxRate: 0.25, campaign: true } as State<{
349
+ priceBeforeTax: number
350
+ taxRate: number
351
+ campaign: boolean
352
+ }>
353
+ ```
354
+
355
+ ### Calling chain functions
356
+
357
+ We have seen how chained or derived States can be created using the `compute` function. Calling *any* chain function on a State will return a State - including for custom objects in a State. Any other States used in supplied callbacks will also be connected to the resulting State.
358
+
359
+ ```typescript
360
+ const s = state({
361
+ fruits: ['banana', 'mango', 'blueberry', 'melon'],
362
+ filterString: 'a',
363
+ customFilter: (value: string) => value.includes('go'),
364
+ })
365
+ s.filteredFruits = s.fruits.filter(fruit => fruit.indexOf(s.filterString) >= 0)
366
+ s.customFilteredFruits = s.fruits.filter(s.customFilter)
367
+ ```
368
+
369
+ In the example, `s.filteredFruits` will be updated whenever the `s.fruits` array is updated, whenever any of its items are updated, and also whenever `s.filterString` is updated. The value of `s.customFilteredFruits` will also be updated whenever the `s.customFilter` *function* is updated.
370
+
371
+ The only exception to this rule is standard Array functions that modify the array, such as `.push`, `.sort`, `.splice`, etc, since such a State would serve no purpose.
372
+
373
+ ### Serialising and deserialising
374
+
375
+ As long as a State only contains valid JSON values, a State can be serialized:
376
+
377
+ `JSON.stringify(s)`
378
+
379
+ Conversely, State can be initiated from stored JSON:
380
+
381
+ `state(JSON.parse(jsonString))`
382
+
383
+ ### Awaiting State changes
384
+
385
+ Snapse States are thenables, which means it is possible to use `await` to halt code execution until the State changes value.
386
+
387
+ ```typescript
388
+ s.confirmDialogOpen.set(true)
389
+ await s.confirmDialogResponse
390
+ if (String(s.confirmDialogResponse) !== 'confirmed') return
391
+ ```
392
+
393
+ ### State error handling
394
+
395
+ If the calculation of a chained state throws an error, it will result in a State that, when accessed, immediately re-throws that error. When the State is used in the DOM, it can be convenient to handle using the `.catch` function. However, if you want to detect State errors in other code, use a try ... catch.
396
+
397
+ ```typescript
398
+ const total = compute(() => {
399
+ if (+s.locked) throw new Error('order total unavailable while locked')
400
+ return s.price * s.quantity
401
+ })
402
+
403
+ let message = ''
404
+ try {
405
+ String(total)
406
+ } catch (e) {
407
+ message = (e as Error).message
408
+ }
409
+ ```
410
+
411
+ If a State is calculated using a function that makes it self-referential, directly or through a series of other computed States, its computation throws a `CircularDependencyError`.
412
+
413
+ ```typescript
414
+ const s = state<number[]>([])
415
+ const next = compute(() => s[0]! + 1)
416
+ s.push(next)
417
+
418
+ const display = div(next).catch('circular dependency')
419
+ ```
420
+
421
+ ## State chain functions
422
+
423
+ Snapse defines a number of chain functions that can be more convenient to use than `compute` or a stateful function.
424
+
425
+ ### .is: test for strict equality
426
+
427
+ ```typescript
428
+ is(value: any): State<boolean>
429
+
430
+ ```
431
+
432
+ The `.is` chain function checks a State for strict equality against another value, which may also be a State.
433
+
434
+ ```typescript
435
+ const isSelected = s.selectedRowId.is(currentRow.id)
436
+ ```
437
+
438
+ ### .not: create an inverse State
439
+
440
+ ```typescript
441
+ not(value: any): State<boolean>
442
+
443
+ ```
444
+
445
+ Note that the result will be a State, not a boolean: see `peek` for how to compare boolean States. The .not chain function is useful when you need to pass an inverted State, for instance to a `disabled` DOM element attribute.
446
+
447
+ ```typescript
448
+ input({ disabled: s.locked.not() })
449
+ ```
450
+
451
+ ### .opt: select State values
452
+
453
+ ```typescript
454
+ opt(options: any | any[] | Record<string, any> | State<any>, defaultValue?: any | State<any>): State<any>
455
+
456
+ ```
457
+
458
+ Often, it is necessary to choose between two or more values, for instance when rendering a Component. The custom `.opt` chain function can simplify this. A ternary or `switch` statement inside a `compute` function can often do the same job, so it is a matter of convenience.
459
+
460
+ * If `options` is a primitive, it will be returned if the calling State is truthy. Otherwise, `defaultValue` will be returned (or `null` if not given).
461
+ * If `options` is an Array and the calling State evaluates to a number, or if `options` is an object literal, the value mapped to the State's current value (`options[stateValue]`) will be returned. If the key does not exist, `defaultValue` or `null` will be returned.
462
+
463
+ ```typescript
464
+ s.locked.opt(LockIcon())
465
+ s.selectedIndex.opt(s.data, 'Nothing selected')
466
+ ```
467
+
468
+ This works the same way for a State that holds a DOM element or component, not just plain values:
469
+
470
+ ```typescript
471
+ const selected = state<DOMChild>(null)
472
+ const checkmark = selected.opt(CheckIcon())
473
+ ```
474
+
475
+ ### .or: fallback for falsy values and empty arrays
476
+
477
+ ```typescript
478
+ or(fallback: any): State<any>
479
+
480
+ ```
481
+
482
+ The `.or` chain function creates a chained State which will be the original State if it is truthy or a non-empty array, otherwise a fallback value.
483
+
484
+ ```typescript
485
+ return s.listItems.or('List is empty')
486
+ ```
487
+
488
+ A State that may or may not hold a rendered element can use `.or` the same way, to fall back while it doesn't:
489
+
490
+ ```typescript
491
+ const selected = state<DOMChild>(null)
492
+ const display = selected.or(div('Nothing selected'))
493
+ ```
494
+
495
+ ## DOM elements
496
+
497
+ In Snapse, the DOM tree is described using functions and values that represent elements and components. Elements and components are freely nested and mixed as children of one another.
498
+
499
+ ```typescript
500
+ const MainComponent = component(() => div({ class: 'main' }, 'Main content'))
501
+ type FooterProps = { size: string, children: ChildProp[] }
502
+ const Footer = component(({ size, children }: FooterProps) =>
503
+ div({ class: `footer-${size}` }, children))
504
+
505
+ const page = div({ class: 'page' },
506
+ MainComponent,
507
+ Footer({ size: 'large' },
508
+ 'Snapse example',
509
+ ),
510
+ )
511
+ ```
512
+
513
+ ### element functions: create DOM elements
514
+
515
+ ```typescript
516
+ type StyleValue = Record<string, string | State<string>> | State<any>
517
+ type ClassObject = Record<string, boolean | State<boolean>>
518
+ type ClassValue = string | ClassObject | ClassValue[] | State<any>
519
+
520
+ type EventListenerOptions = boolean | AddEventListenerOptions
521
+ type EventListenerValue = Function | [Function, EventListenerOptions] | State<any>
522
+
523
+ type AttributesAndEventListeners = {
524
+ style?: StyleValue
525
+ class?: ClassValue
526
+ [key: string]: string | StyleValue | ClassValue | EventListenerValue | any
527
+ }
528
+
529
+ type ChildProp = Component | DOMElement | string | number | null | ChildProp[]
530
+ | State<Component | DOMElement | string | number | null>
531
+
532
+ function element(attributesAndEventListeners?: AttributesAndEventListeners, ...children: ChildProp[]): DOMElement
533
+
534
+ ```
535
+
536
+ Standard elements such as `div` and `span` are exported from `snapse/DOM`. Tag attributes are optionally specified in a plain object as the first argument to the function. The remaining arguments are treated as children.
537
+
538
+ Any attribute, child or argument can also be a State. In that case, Snapse will reactively update the DOM whenever the value of the State changes.
539
+
540
+ ```typescript
541
+ div({ id: 'greeting', class: 'banner' }, 'Hello, ', s.name, '!')
542
+ ```
543
+
544
+ ### SVG elements
545
+
546
+ SVG element functions (`svg`, `circle`, `path`, …) are imported from `snapse/SVG`; their event handlers are typed with `SVGHandler`. Attributes and reactivity work exactly as for DOM elements. Some names (`a`, `script`, `style` and `title`) clash with DOM elements - use an import alias if you need both in the same file. When loading `snapse.js` directly in a web page, these SVG elements are named `svg_a`, `svg_script`, `svg_style` and `svg_title`.
547
+
548
+ ```typescript
549
+ // SVG element functions come from snapse/SVG; they work like DOM elements.
550
+ svg({ viewBox: '0 0 100 100', width: 100 },
551
+ circle({ cx: 50, cy: 50, r: 40, fill: 'tomato' }))
552
+ ```
553
+
554
+ ### Element attributes
555
+
556
+ Snapse passes on any attributes to the DOM element, including custom attributes for custom elements. A boolean attribute will be interpreted as set/unset.
557
+
558
+ ```typescript
559
+ // A boolean attribute is rendered when truthy and omitted when falsy; both plain
560
+ // and reactive values are accepted, as is any custom attribute.
561
+ input({ type: 'text', required: true, disabled: s.busy, 'data-role': 'title' })
562
+ ```
563
+
564
+ ### Attribute interpolation
565
+
566
+ To join a State with a string, pass an array as the attribute value. This works for whole attributes and for individual `style` values; any State among the elements keeps the result reactive.
567
+
568
+ ```typescript
569
+ // Arrays are joined into one value; a State among them keeps it reactive.
570
+ const box = div({ id: 'sidebar', style: { width: [s.width, 'px'] } })
571
+ ```
572
+
573
+ ### Events
574
+
575
+ ```typescript
576
+ type EventHandlerValue =
577
+ | ((event: Event) => void)
578
+ | [(event: Event) => void, EventListenerOptions]
579
+ | State<any>
580
+
581
+ ```
582
+
583
+ Snapse registers an attribute `xyz` as a listener for the `xyz` event when its value is a function, or a `[function, options]` tuple for `addEventListener` options such as `{ once: true }` or `{ capture: true }`.
584
+
585
+ ```typescript
586
+ const s = state({ clicks: 0 })
587
+ // const countClick = () => s.clicks++
588
+ const counter = button({ click: () => s.clicks++ }, 'Clicked ', s.clicks, ' times')
589
+ ```
590
+
591
+ #### Binding States to output
592
+
593
+ Passing a State as the handler is a convenient alternative to `ev => s.text.set(ev.target.value)`. Combined with a matching `value`, `checked` or `selected` attribute it gives output binding with no handler code - for text inputs, checkboxes, `select`, and any control whose event target carries the value.
594
+
595
+ ```typescript
596
+ const s = state({ name: '', agreed: false, size: 'M' })
597
+ // Passing s.name (a State) is equivalent to `ev => s.name.set(ev.target.value)`.
598
+ div(
599
+ input({ value: s.name, input: s.name }),
600
+ input({ type: 'checkbox', checked: s.agreed, change: s.agreed }),
601
+ select({ value: s.size, change: s.size },
602
+ option({ value: 'S' }, 'Small'),
603
+ option({ value: 'M' }, 'Medium')),
604
+ )
605
+ ```
606
+
607
+ ### The class attribute
608
+
609
+ For the `class` attribute, Snapse supports nesting classes using arrays, as well as setting classes conditionally using object notation (`ClassObject`):
610
+
611
+ ```typescript
612
+ div(
613
+ {
614
+ class: [
615
+ 'standard-div',
616
+ s.customClass,
617
+ {
618
+ 'special-div': s.isSpecial,
619
+ },
620
+ ],
621
+ },
622
+ content,
623
+ )
624
+ ```
625
+
626
+ ### Element children
627
+
628
+ Children can be other elements, Snapse components, strings, numbers or `null` (which renders nothing). Arrays are automatically expanded. If the argument is a function, the function will be called and the result will be inserted. This allows creating lightweight 'anonymous components'.
629
+
630
+ ```typescript
631
+ const s = state({
632
+ background: 'red',
633
+ buttonText: 'Ok',
634
+ })
635
+ div({ style: { background: s.background } },
636
+ button({ click: () => alert('Clicked') }, s.buttonText))
637
+ ```
638
+
639
+ A child can also be a State, used to imperatively switch the display.
640
+
641
+ ```typescript
642
+ const view = state<DOMChild>(null)
643
+ const showWelcome = () => view.set(div('Welcome!'))
644
+ const showProfile = () => view.set(div({ class: 'profile' }, 'Your profile'))
645
+ const app = div({ id: 'app' }, view)
646
+ ```
647
+
648
+ ### domElement: create a custom DOM element factory
649
+
650
+ ```typescript
651
+ function domElement(tagName: string): (...args: any[]) => DOMElement
652
+
653
+ ```
654
+
655
+ To use custom elements with Snapse, use `domElement` to create the corresponding element function.
656
+
657
+ ```typescript
658
+ const specialElement = domElement('special-element')
659
+
660
+ specialElement({ excel: true, prevaricate: 'maybe' },
661
+ div('Excellent child'))
662
+ ```
663
+
664
+ ## Components
665
+
666
+ Snapse has two types of components: feature-rich proper Snapse components, and plain JS functions that are suitable for code refactoring without extra boilerplate.
667
+
668
+ ### component: define Snapse components
669
+
670
+ ```typescript
671
+ function component<P>(functionBody: (properties: P & { children?: ChildProp[] }) => ChildProp): Component
672
+
673
+ ```
674
+
675
+ Once defined, components can be used just like DOM elements. In addition to any custom properties the caller submits, the component function body will receive the special `children` parameter containing any children nested when invoking the component. The function body will be called only once, when your component is created.
676
+
677
+ ```typescript
678
+ const SearchableTable = component(({ rowData }: { rowData: State<string[][]> }) => {
679
+ const query = state('')
680
+ const filterFunction = (row: State<string[]>) => row.some(cell => cell.includes(query))
681
+
682
+ return div(
683
+ table(
684
+ tbody(
685
+ rowData.filter(filterFunction)
686
+ .map((row: string[]) => tr(
687
+ row.map((cellData: string) => td(cellData)),
688
+ )),
689
+ ),
690
+ tfoot(
691
+ tr(td(input({ change: query }))),
692
+ ),
693
+ ),
694
+ )
695
+ })
696
+ ```
697
+
698
+ ### async components: defer display
699
+
700
+ When the function body is an async function, the component will return `null` and display nothing until the function resolves. In an async component, calls to `state`, `findState` and `subscribe` must be placed before the first use of `await`, or an error will be thrown.
701
+
702
+ ```typescript
703
+ const UserCard = component(async () => {
704
+ const user = await loadUser()
705
+ return div({ class: 'card' }, user.name)
706
+ })
707
+ ```
708
+
709
+ ### .or: display a fallback
710
+
711
+ ```typescript
712
+ or(fallback: ChildProp): Component
713
+
714
+ ```
715
+
716
+ If a component can return `null`, typically as a State, or if it is an async component, it is possible to display a fallback.
717
+
718
+ ```typescript
719
+ const page = div(UserCard().or('Loading...'))
720
+ ```
721
+
722
+ The same fallback applies if you hold the component in a State.
723
+
724
+ ```typescript
725
+ const view = state(UserCard()).or('Loading...')
726
+ const page = div(view)
727
+ ```
728
+
729
+ ### Function components: minimal boilerplate
730
+
731
+ In Snapse, any function is also a lightweight component. If the function needs no argument, it can just be added as a child.
732
+
733
+ ```typescript
734
+ const simpleComponent = () => div('example text')
735
+ const page = div(simpleComponent)
736
+ ```
737
+
738
+ This is useful for refactoring your DOM code to avoid excessive depth, with an absolute minimum of boilerplate.
739
+
740
+ If a function component needs arguments, it can be invoked using an anonymous function.
741
+
742
+ ```typescript
743
+ const argumentComponent = (n: number) => div('the answer is ', n)
744
+ const page = div(() => argumentComponent(42))
745
+ ```
746
+
747
+ Note that if you simply call the function directly, it may also work - but this means your 'component' will be evaluated, and all its children will be created, at the time you define the parent component. While this is perfectly valid, the resulting behaviour can be counter-intuitive.
748
+
749
+ ### .catch: handle errors
750
+
751
+ ```typescript
752
+ type CatchFallbackFunction = (renderError: RenderError) => ChildProp
753
+ type CatchFallback = ChildProp | CatchFallbackFunction | State<CatchFallbackFunction>
754
+
755
+ catch(fallback: CatchFallback): this
756
+
757
+ ```
758
+
759
+ Every element, component and `SnapseRoot` has a `.catch()` chain function. It catches errors thrown while building or updating its subtree - both during initial construction and during later reactive recomputation - and replaces the affected content with `fallback` instead of leaving it in a broken state. The fallback can be a plain child value, a Component, or a function (or `State` of one) that receives the `RenderError` and returns the replacement content.
760
+
761
+ ```typescript
762
+ const Weather = component(() => {
763
+ throw new Error('forecast service unavailable')
764
+ })
765
+
766
+ const page = div(Weather().catch('Unable to load weather'))
767
+ ```
768
+
769
+ If an error occurs beneath more than one `.catch()`, the nearest one wins. Outer `.catch()` calls are still notified of the error (a function fallback is invoked for any side effects), but their result is only mounted if the nearer fallback itself rendered nothing.
770
+
771
+ ### RenderError: fine-grained error handling
772
+
773
+ ```typescript
774
+ class RenderError extends Error {
775
+ readonly cause: unknown
776
+ readonly fallbackRendered: boolean
777
+ readonly propagationStopped: boolean
778
+
779
+ reset(): void
780
+ stopPropagation(): void
781
+ }
782
+
783
+ ```
784
+
785
+ The `RenderError` passed to a `.catch()` fallback carries the original error as `cause`. Calling `reset()` re-attempts the original render in place, replacing the fallback once it succeeds - this is the basis for "retry" buttons. A fallback that is itself a Component receives the `RenderError` as its `renderError` prop, rather than as a call argument.
786
+
787
+ ```typescript
788
+ let widgetBroken = true
789
+ const FlakyWidget = component(() => {
790
+ if (widgetBroken) throw new Error('widget crashed')
791
+ return div('widget ok')
792
+ })
793
+
794
+ const Fallback = component(({ renderError }) => div(
795
+ 'Failed: ',
796
+ (renderError!.cause as Error).message,
797
+ ' ',
798
+ button({ click: () => renderError!.reset() }, 'Retry'),
799
+ ))
800
+
801
+ const page = div(FlakyWidget().catch(Fallback()))
802
+ ```
803
+
804
+ Calling `stopPropagation()` prevents any further, outer `.catch()` from being notified of the error at all.
805
+
806
+ ```typescript
807
+ let outerNotified = false
808
+ const Throwing = component(() => {
809
+ throw new Error('boom')
810
+ })
811
+
812
+ const Inner = component(() => Throwing().catch(e => {
813
+ e.stopPropagation() // Prevents the outer .catch() below from also being notified.
814
+ return 'inner handled'
815
+ }))
816
+
817
+ const page = div(Inner().catch(() => {
818
+ outerNotified = true
819
+ return 'outer handled'
820
+ }))
821
+ ```
822
+
823
+ ## SnapseRoot: bootstrap the Snapse application or add a 'portal'
824
+
825
+ ```typescript
826
+ type RenderMode = 'hydrate' | 'csr'
827
+ type RootOptions = { root?: Node; mode?: RenderMode }
828
+
829
+ function SnapseRoot(options?: RootOptions, child?: ChildProp): void
830
+ function SnapseRoot(child: ChildProp): void
831
+
832
+ ```
833
+
834
+ If no root is specified, Snapse will install at the end of `document.body`.
835
+
836
+ The available modes are:
837
+
838
+ * `hydrate`: attach event listeners and reactive State to existing DOM elements, then switch to `csr` mode
839
+ * `csr` (default): create DOM elements, install event listeners and reactive State
840
+
841
+ ```typescript
842
+ SnapseRoot(MyComponent)
843
+ ```
844
+
845
+ ### Rendering a portal
846
+
847
+ If `SnapseRoot` is passed as a child of an element or component, its children will be rendered in the specified root, or at the end of `document.body`, creating a 'portal'. This subtree will be removed if the `SnapseRoot` component is removed.
848
+
849
+ ```typescript
850
+ const Dialog = component(() => SnapseRoot(
851
+ { root: document.getElementById('modal-root') },
852
+ div({ class: 'dialog' }, 'Saved!'),
853
+ ))
854
+
855
+ const Page = component(() => div('Page content', s.dialogOpen.opt(Dialog)))
856
+ ```
857
+
858
+ ## Custom attributes & events
859
+
860
+ Snapse offers some custom attributes and event handlers.
861
+
862
+ ### nodeCreated: notify on element creation
863
+
864
+ ```typescript
865
+ nodeCreated?: (element: DOMElement) => void
866
+
867
+ ```
868
+
869
+ The `nodeCreated` callback will fire as soon as the element has been created, before it is installed in the DOM.
870
+
871
+ ```typescript
872
+ div({ nodeCreated: (element: { target?: HTMLElement }) => console.log(element) })
873
+ ```
874
+
875
+ ### nodeInserted: notify on addition to DOM
876
+
877
+ ```typescript
878
+ nodeInserted?: (element: DOMElement) => void
879
+
880
+ ```
881
+
882
+ The `nodeInserted` callback will fire as soon as the element has been installed in the DOM.
883
+
884
+ ```typescript
885
+ div({ nodeInserted: (element: { target?: HTMLElement }) => console.log(element) })
886
+ ```
887
+
888
+ ### nodeRemoved: notify after removal from DOM
889
+
890
+ ```typescript
891
+ nodeRemoved?: () => void
892
+
893
+ ```
894
+
895
+ The handler is called just after the element has been removed. It receives no arguments.
896
+
897
+ ```typescript
898
+ div({ nodeRemoved: () => console.log('Element was removed') })
899
+ ```
900
+
901
+ ### innerHTML: insert raw HTML content
902
+
903
+ ```typescript
904
+ innerHTML?: string
905
+
906
+ ```
907
+
908
+ Inserting HTML creates a security risk, so it is important to use this feature only with trusted data, such as content received from a CMS system. A `DOMElement` can either have an `innerHTML` attribute or children, not both.
909
+
910
+ ```typescript
911
+ div({ innerHTML: 'Some text with <em>markup</em>.' })
912
+ ```
913
+
914
+ ### `null`: The do-nothing value
915
+
916
+ Snapse ignores ``null`` values as children, DOM Node properties, or event handlers. In order to deactivate something, create a State and set it to ``null`` to deactivate.
917
+
918
+ ```typescript
919
+ SnapseRoot(component(() => {
920
+ const s = state({
921
+ disabled: false,
922
+ onClick: (() => {
923
+ console.log('clicked, disabling')
924
+ s.disabled.set(true)
925
+ s.onClick.set(null)
926
+ }) as (() => void) | null,
927
+ })
928
+ const onMouseOver = s.disabled.opt(() => console.log('mouse over'))
929
+
930
+ return button({ disabled: s.disabled, click: s.onClick, mouseover: onMouseOver })
931
+ })())
932
+ ```
933
+
934
+ ## Moving content
935
+
936
+ Snapse will move DOM elements using moveBefore when supported by the browser, preserving their internal state. Snapse will also move existing Snapse components when they are added using an array that sits directly in a DOM element.
937
+
938
+ ```typescript
939
+ const Greeting = component(() => div({ class: 'greeting' }, 'Hello from a component'))
940
+
941
+ const left = state<DOMChild[]>([input({ placeholder: 'Type here' }), Greeting()])
942
+ const right = state<DOMChild[]>([])
943
+
944
+ const moveRight = () => right.push(...left.splice(0))
945
+
946
+ const page = div(
947
+ div({ class: 'left' }, left),
948
+ div({ class: 'right' }, right),
949
+ button({ click: moveRight }, 'Move'),
950
+ )
951
+ ```
952
+
953
+ ## Server-side rendering
954
+
955
+ ```typescript
956
+ import { renderToString } from 'snapse/server'
957
+
958
+ function renderToString(...children: ChildProp[]): string
959
+
960
+ ```
961
+
962
+ `renderToString` renders a tree to an HTML string. It runs anywhere JavaScript does: Node, Deno, Bun, or an edge worker.
963
+
964
+ ```typescript
965
+ import { renderToString } from 'snapse/server'
966
+ import { component, state } from 'snapse'
967
+ import { button, div, span } from 'snapse/DOM'
968
+ ```
969
+
970
+ ```typescript
971
+ const Counter = component(() => {
972
+ const s = state({ count: 41 })
973
+ return div(span(s.count), button({ click: () => s.count++ }, 'Count'))
974
+ })
975
+
976
+ const html = renderToString(Counter())
977
+ ```
978
+
979
+ When a page containing the resulting string has been loaded in a browser, it can be hydrated using the *same* tree to `SnapseRoot` in `hydrate` mode to attach behavior to the elements:
980
+
981
+ ```typescript
982
+ SnapseRoot({ mode: 'hydrate' }, Counter())
983
+ ```
984
+
985
+ Snapse needs to mark the boundaries for dynamic content which is not bounded by an element. Therefore, you need to make sure that any minification preserves the Snapse boundary.
986
+ ```
987
+ <!--𝒮-->
988
+ ```
989
+
990
+ Note that the `SnapseRoot` component does not render anything when calling the `renderToString` function. Normally, it is used for elements that are hidden by default, and will be inserted with client side rendering. If you do want to render into multiple roots, you need to call `renderToString` for each tree separately in your server code, and initialize each case separately with `SnapseRoot` in the browser.
991
+
992
+ ## Undo functionality
993
+
994
+ Snapse offers a powerful mechanism to support undo, redo and point-in-time restore features in your application with minimum effort.
995
+
996
+ ### TrackHistory
997
+
998
+ ```typescript
999
+ type TrackHistoryOptions = { limit?: number; addHistoryVersion?: () => number }
1000
+ type HistoryEntry = { batch: ChangeRecord[]; t: number }
1001
+
1002
+ class TrackHistory<S extends State> {
1003
+ constructor(root: S, options?: TrackHistoryOptions)
1004
+
1005
+ undo(): boolean
1006
+ redo(): boolean
1007
+ restoreAt(timestamp: number): void
1008
+
1009
+ startBatch(): void
1010
+ commitBatch(): void
1011
+
1012
+ getHistory(): HistoryEntry[]
1013
+ setHistory(history: HistoryEntry[]): void
1014
+ unsubscribe(): void
1015
+
1016
+ undoEmpty: State<boolean>
1017
+ redoEmpty: State<boolean>
1018
+ historyVersions: State<number[]>
1019
+ }
1020
+
1021
+ ```
1022
+
1023
+ Construct it with the State you want tracked as `root`; this must be an object or array State. Every change to `root` or any of its descendants is recorded from then on. All changes applied between two flushes become a single history entry.
1024
+
1025
+ Options:
1026
+
1027
+ * `limit`: the maximum number of entries kept on the undo stack. Older entries are discarded.
1028
+ * `addHistoryVersion`: called once per entry to produce its timestamp, default is `Date.now()`. Return a monotonically increasing number.
1029
+
1030
+ Members:
1031
+
1032
+ * `undo()` / `redo()`: step one entry back or forward. Each returns `false` if there was nothing to do. The tracked State is updated synchronously.
1033
+ * `restoreAt(timestamp)`: move the tracked State to the point in time given by `timestamp`, undoing or redoing as many entries as needed in either direction.
1034
+ * `startBatch()` / `commitBatch()`: collapse every change made between these calls, across any number of flushes, into a single undo entry. Committing with no changes recorded adds nothing.
1035
+ * `getHistory()` / `setHistory(history)`: read the undo stack, or replace it, for persistence between sessions. `setHistory` also clears the redo stack.
1036
+ * `unsubscribe()`: stop recording. Call this when the TrackHistory is no longer needed and was not created inside a Component.
1037
+ * `undoEmpty` / `redoEmpty`: boolean States that track whether each stack is empty - wire them to the `disabled` attribute of undo/redo buttons.
1038
+ * `historyVersions`: a State holding the array of entry timestamps, newest last - useful for rendering a history timeline for `restoreAt`.
1039
+
1040
+ ```typescript
1041
+ const Counter = component(() => {
1042
+ const s = state({ count: 0 })
1043
+ const history = new TrackHistory(s)
1044
+
1045
+ return div(
1046
+ output(s.count),
1047
+ button({ click: () => s.count.set(s.count + 1) }, 'Increment'),
1048
+ button({ click: history.undo, disabled: history.undoEmpty }, 'Undo'),
1049
+ button({ click: history.redo, disabled: history.redoEmpty }, 'Redo'),
1050
+ )
1051
+ })
1052
+ ```
1053
+
1054
+ ### Limitations of TrackHistory
1055
+
1056
+ While TrackHistory can be sufficient for many use cases, there are limitations you need to consider.
1057
+
1058
+ All the undoable application state needs to be in the tracked State, possibly including data that should not be directly displayed in the DOM. If your application data is very large, this may not be efficient.
1059
+
1060
+ TrackHistory does not support storing duplicated State. For example, the following is not supported:
1061
+
1062
+ ```typescript
1063
+ // NOT supported: selectedItem now holds the very same State that also lives in
1064
+ // items, so TrackHistory sees one child stored in two places and cannot track it.
1065
+ board.selectedItem.set([board.items[42]!])
1066
+ ```
1067
+
1068
+ TrackHistory also does not support circular references among its children.
1069
+
1070
+ Finally, since TrackHistory is a highly generic implementation, it is always possible to design a more efficient implementation for your application, albeit at a much higher cost of development.
1071
+
1072
+ ## Example
1073
+
1074
+ ```typescript
1075
+ import { SnapseRoot, type State, component, findState, flush, state, subscribe }
1076
+ from 'snapse'
1077
+ import { type ChildProp, type DOMHandler, button, div, h2, input, output, span }
1078
+ from 'snapse/DOM'
1079
+ import cl from './styles.module.css'
1080
+
1081
+ type TodoItem = State<{ id: number, checked: boolean, text: string }>
1082
+ type TodoListState = State<{
1083
+ items: TodoItem[]
1084
+ query: string
1085
+ selected: number
1086
+ }>
1087
+
1088
+ export const TodoList = component(() => {
1089
+ const s = state<TodoListState>({
1090
+ items: JSON.parse(localStorage.getItem('ToDo') ?? '[]'),
1091
+ query: '',
1092
+ selected: 0,
1093
+ }, 'TodoList')
1094
+ let nextId = Number(s.items.reduce((max, item) => Math.max(max, item.id), 0))
1095
+ subscribe(s.items,
1096
+ () => localStorage.setItem('ToDo', JSON.stringify(s.items)),
1097
+ { deep: true })
1098
+ const addTodo: DOMHandler<'change'> = ev => {
1099
+ s.items.push({ id: ++nextId, checked: false, text: ev.target.value } as TodoItem)
1100
+ ev.target.value = ''
1101
+ }
1102
+ const matchingItems = s.items.filter(item => String(item.text).includes(s.query))
1103
+ const itemComponents = matchingItems.map(todoItem => Todo({ todoItem })
1104
+ .or(div({ class: cl.item }, 'Creating...')))
1105
+
1106
+ return div({ class: cl.app },
1107
+ div({ class: cl.lists },
1108
+ div(
1109
+ h2('To do'),
1110
+ itemComponents.filter((_item, i) => matchingItems[i]!.checked.not())
1111
+ .or(output(s.query.opt(["No match for '", s.query, "'"], 'Nothing to do'))),
1112
+ ),
1113
+ div({ class: cl.done },
1114
+ h2('Done'),
1115
+ itemComponents.filter((_item, i) => matchingItems[i]!.checked)
1116
+ .or(output(s.query.opt(["No match for '", s.query, "'"], 'Nothing finished'))),
1117
+ ),
1118
+ input({ class: cl.search, type: 'search', placeholder: 'Search', input: s.query }),
1119
+ ),
1120
+ div(input({ class: cl.add, placeholder: 'Add todo', change: addTodo })),
1121
+ )
1122
+ })
1123
+
1124
+ const startViewTransition = document.startViewTransition?.bind(document)
1125
+ ?? ((f: Function) => f())
1126
+ type TodoItemProps = { todoItem: TodoItem }
1127
+ const Todo = component(async ({ todoItem }: TodoItemProps) => {
1128
+ const s: TodoListState = findState('TodoList')
1129
+ const handleCheck: DOMHandler<'change'> = ev => startViewTransition(() => {
1130
+ todoItem.checked.set(ev.target.checked)
1131
+ flush()
1132
+ })
1133
+ await new Promise(r => setTimeout(r, 300 + Math.random() * 700)) // Make it expensive
1134
+
1135
+ return div({ class: cl.item, style: { viewTransitionName: `item${todoItem.id}` } },
1136
+ input({ type: 'checkbox', checked: todoItem.checked, change: handleCheck }),
1137
+ s.selected.is(todoItem.id).opt(
1138
+ ItemEditor({ todoItem, todoListState: s }),
1139
+ span({ click: () => s.selected.set(todoItem.id) },
1140
+ HighlightedText({ text: todoItem.text, highlight: s.query }),
1141
+ ),
1142
+ ),
1143
+ )
1144
+ })
1145
+
1146
+ type ItemEditorProps = { todoItem: TodoItem, todoListState: TodoListState }
1147
+ const ItemEditor = component(({ todoItem, todoListState: s }: ItemEditorProps) => {
1148
+ const updateTodoText: DOMHandler<'change'> = ev => {
1149
+ todoItem.text.set(ev.target.value)
1150
+ s.selected.set(0)
1151
+ }
1152
+ const deleteTodo = () => s.items.splice(s.items.indexOf(todoItem), 1)
1153
+
1154
+ return [
1155
+ input({ value: todoItem.text, change: updateTodoText, blur: updateTodoText,
1156
+ nodeInserted: ev => ev.target?.focus() }),
1157
+ button({ class: cl.delete, mousedown: deleteTodo }),
1158
+ ]
1159
+ })
1160
+
1161
+ type HighLightedTextProps = State<{ text: string, highlight: string }>
1162
+ const HighlightedText = component(({ text, highlight }: HighLightedTextProps) =>
1163
+ text.split(highlight.or(undefined) as string)
1164
+ .reduce((acc, fragment, i) => i
1165
+ ? [...acc, span({ class: cl.highlight }, highlight), fragment]
1166
+ : [fragment], [] as (string | ChildProp)[]),
1167
+ )
1168
+
1169
+ SnapseRoot(TodoList())
1170
+ ```