@uistate/examples 1.0.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 (137) hide show
  1. package/README.md +40 -0
  2. package/cssState/.gitkeep +0 -0
  3. package/eventState/001-counter/README.md +44 -0
  4. package/eventState/001-counter/index.html +33 -0
  5. package/eventState/002-counter-improved/README.md +44 -0
  6. package/eventState/002-counter-improved/index.html +47 -0
  7. package/eventState/003-input-reactive/README.md +44 -0
  8. package/eventState/003-input-reactive/index.html +33 -0
  9. package/eventState/004-computed-state/README.md +45 -0
  10. package/eventState/004-computed-state/index.html +65 -0
  11. package/eventState/005-conditional-rendering/README.md +42 -0
  12. package/eventState/005-conditional-rendering/index.html +39 -0
  13. package/eventState/006-list-rendering/README.md +49 -0
  14. package/eventState/006-list-rendering/index.html +63 -0
  15. package/eventState/007-form-validation/README.md +52 -0
  16. package/eventState/007-form-validation/index.html +102 -0
  17. package/eventState/008-undo-redo/README.md +70 -0
  18. package/eventState/008-undo-redo/index.html +108 -0
  19. package/eventState/009-localStorage-side-effects/README.md +72 -0
  20. package/eventState/009-localStorage-side-effects/index.html +57 -0
  21. package/eventState/010-decoupled-components/README.md +74 -0
  22. package/eventState/010-decoupled-components/index.html +93 -0
  23. package/eventState/011-async-patterns/README.md +98 -0
  24. package/eventState/011-async-patterns/index.html +132 -0
  25. package/eventState/028-counter-improved-eventTest/LICENSE +55 -0
  26. package/eventState/028-counter-improved-eventTest/README.md +131 -0
  27. package/eventState/028-counter-improved-eventTest/app/store.js +9 -0
  28. package/eventState/028-counter-improved-eventTest/index.html +49 -0
  29. package/eventState/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
  30. package/eventState/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
  31. package/eventState/028-counter-improved-eventTest/runtime/core/eventStateNew.js +149 -0
  32. package/eventState/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
  33. package/eventState/028-counter-improved-eventTest/runtime/core/router.js +271 -0
  34. package/eventState/028-counter-improved-eventTest/store.d.ts +8 -0
  35. package/eventState/028-counter-improved-eventTest/style.css +170 -0
  36. package/eventState/028-counter-improved-eventTest/tests/README.md +208 -0
  37. package/eventState/028-counter-improved-eventTest/tests/counter.test.js +116 -0
  38. package/eventState/028-counter-improved-eventTest/tests/eventTest.js +176 -0
  39. package/eventState/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
  40. package/eventState/028-counter-improved-eventTest/tests/run.js +20 -0
  41. package/eventState/030-todo-app-with-eventTest/LICENSE +55 -0
  42. package/eventState/030-todo-app-with-eventTest/README.md +121 -0
  43. package/eventState/030-todo-app-with-eventTest/app/router.js +25 -0
  44. package/eventState/030-todo-app-with-eventTest/app/store.js +16 -0
  45. package/eventState/030-todo-app-with-eventTest/app/views/home.js +11 -0
  46. package/eventState/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
  47. package/eventState/030-todo-app-with-eventTest/index.html +65 -0
  48. package/eventState/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  49. package/eventState/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  50. package/eventState/030-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  51. package/eventState/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  52. package/eventState/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
  53. package/eventState/030-todo-app-with-eventTest/store.d.ts +18 -0
  54. package/eventState/030-todo-app-with-eventTest/style.css +170 -0
  55. package/eventState/030-todo-app-with-eventTest/tests/README.md +208 -0
  56. package/eventState/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
  57. package/eventState/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
  58. package/eventState/030-todo-app-with-eventTest/tests/run.js +20 -0
  59. package/eventState/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
  60. package/eventState/031-todo-app-with-eventTest/LICENSE +55 -0
  61. package/eventState/031-todo-app-with-eventTest/README.md +54 -0
  62. package/eventState/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
  63. package/eventState/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  64. package/eventState/031-todo-app-with-eventTest/app/bridges.js +113 -0
  65. package/eventState/031-todo-app-with-eventTest/app/router.js +26 -0
  66. package/eventState/031-todo-app-with-eventTest/app/store.js +15 -0
  67. package/eventState/031-todo-app-with-eventTest/app/views/home.js +46 -0
  68. package/eventState/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  69. package/eventState/031-todo-app-with-eventTest/devtools/dock.js +41 -0
  70. package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  71. package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  72. package/eventState/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  73. package/eventState/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  74. package/eventState/031-todo-app-with-eventTest/index.html +103 -0
  75. package/eventState/031-todo-app-with-eventTest/package-lock.json +2184 -0
  76. package/eventState/031-todo-app-with-eventTest/package.json +24 -0
  77. package/eventState/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  78. package/eventState/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  79. package/eventState/031-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  80. package/eventState/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  81. package/eventState/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
  82. package/eventState/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  83. package/eventState/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  84. package/eventState/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  85. package/eventState/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  86. package/eventState/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  87. package/eventState/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  88. package/eventState/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  89. package/eventState/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  90. package/eventState/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  91. package/eventState/031-todo-app-with-eventTest/store.d.ts +23 -0
  92. package/eventState/031-todo-app-with-eventTest/style.css +170 -0
  93. package/eventState/031-todo-app-with-eventTest/tests/README.md +208 -0
  94. package/eventState/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
  95. package/eventState/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  96. package/eventState/031-todo-app-with-eventTest/tests/run.js +20 -0
  97. package/eventState/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
  98. package/eventState/032-todo-app-with-eventTest/LICENSE +55 -0
  99. package/eventState/032-todo-app-with-eventTest/README.md +54 -0
  100. package/eventState/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
  101. package/eventState/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
  102. package/eventState/032-todo-app-with-eventTest/app/actions/index.js +153 -0
  103. package/eventState/032-todo-app-with-eventTest/app/bridges.js +113 -0
  104. package/eventState/032-todo-app-with-eventTest/app/router.js +26 -0
  105. package/eventState/032-todo-app-with-eventTest/app/store.js +15 -0
  106. package/eventState/032-todo-app-with-eventTest/app/views/home.js +46 -0
  107. package/eventState/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
  108. package/eventState/032-todo-app-with-eventTest/devtools/dock.js +41 -0
  109. package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
  110. package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
  111. package/eventState/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
  112. package/eventState/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
  113. package/eventState/032-todo-app-with-eventTest/index.html +87 -0
  114. package/eventState/032-todo-app-with-eventTest/package-lock.json +2184 -0
  115. package/eventState/032-todo-app-with-eventTest/package.json +24 -0
  116. package/eventState/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
  117. package/eventState/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
  118. package/eventState/032-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
  119. package/eventState/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
  120. package/eventState/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
  121. package/eventState/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
  122. package/eventState/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
  123. package/eventState/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
  124. package/eventState/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
  125. package/eventState/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
  126. package/eventState/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
  127. package/eventState/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
  128. package/eventState/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
  129. package/eventState/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
  130. package/eventState/032-todo-app-with-eventTest/store.d.ts +23 -0
  131. package/eventState/032-todo-app-with-eventTest/style.css +170 -0
  132. package/eventState/032-todo-app-with-eventTest/tests/README.md +208 -0
  133. package/eventState/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
  134. package/eventState/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
  135. package/eventState/032-todo-app-with-eventTest/tests/run.js +20 -0
  136. package/eventState/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
  137. package/package.json +27 -0
@@ -0,0 +1,210 @@
1
+ // eventState.plus.js — Open/Closed extension over eventState.js without modifying core
2
+ // Provides: safety guards, stricter validation, unsubscribe helper, and a light batch API
3
+ // NOTE: This module composes the existing './eventState.js' implementation and returns
4
+ // an enhanced facade. The original fine-grained semantics (per-path events) remain intact.
5
+
6
+ import createEventStateBase from '../core/eventStateNew.js';
7
+
8
+ /**
9
+ * Create an enhanced EventState store while preserving the original semantics.
10
+ * - Safety: destroyed-guard to prevent use-after-destroy
11
+ * - Validation: strict argument checks for subscribe/off
12
+ * - Ergonomics: off(unsub) helper
13
+ * - Batch: coalesce multiple set() calls by path within a batch() section
14
+ *
15
+ * Important: batching here deduplicates per-path updates within the batch,
16
+ * but still dispatches one notification per unique path at flush time.
17
+ * This preserves fine-grained observability while reducing churn.
18
+ */
19
+ export function createEventStatePlus(initial = {}, options = {}){
20
+ const base = createEventStateBase(initial);
21
+ let destroyed = false;
22
+
23
+ // Track subscriptions we create (optional, for destroy hygiene)
24
+ const _subscriptions = new Set();
25
+
26
+ function assertNotDestroyed(){
27
+ if (destroyed) throw new Error('EventState store has been destroyed');
28
+ }
29
+
30
+ // Batching support: collect last value per path and flush at end
31
+ let batching = false;
32
+ let buffer = new Map(); // path -> value (last write wins)
33
+
34
+ function flushBuffer(){
35
+ if (buffer.size === 0) return;
36
+ const entries = Array.from(buffer.entries());
37
+ buffer.clear();
38
+ for (const [path, value] of entries){
39
+ base.set(path, value);
40
+ }
41
+ }
42
+
43
+ function batch(fn){
44
+ assertNotDestroyed();
45
+ const wasBatching = batching;
46
+ batching = true;
47
+ try {
48
+ fn();
49
+ } finally {
50
+ batching = wasBatching; // support nested batches: only flush on outermost
51
+ if (!batching) flushBuffer();
52
+ }
53
+ }
54
+
55
+ // Facade methods
56
+ function get(path){
57
+ return base.get(path);
58
+ }
59
+
60
+ function set(path, value){
61
+ assertNotDestroyed();
62
+ if (!path) return value;
63
+ if (batching){
64
+ buffer.set(path, value);
65
+ return value;
66
+ }
67
+ return base.set(path, value);
68
+ }
69
+
70
+ function subscribe(path, handler){
71
+ assertNotDestroyed();
72
+ if (typeof path !== 'string' || typeof handler !== 'function'){
73
+ throw new TypeError('subscribe(path, handler) requires a string path and function handler');
74
+ }
75
+ // eventState.js invokes callback(detail, path). We adapt the signature to (detail, meta)
76
+ // where meta mimics an event-like shape with type=path for ergonomics.
77
+ const wrapped = (detail /* from base */, subscribedPath /* string */) => {
78
+ handler(detail, { type: subscribedPath, detail });
79
+ };
80
+ const unsubscribe = base.subscribe(path, wrapped);
81
+ _subscriptions.add(unsubscribe);
82
+ return function off(){
83
+ _subscriptions.delete(unsubscribe);
84
+ return unsubscribe();
85
+ };
86
+ }
87
+
88
+ function off(unsubscribe){
89
+ if (typeof unsubscribe !== 'function'){
90
+ throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
91
+ }
92
+ return unsubscribe();
93
+ }
94
+
95
+ function setMany(entries){
96
+ assertNotDestroyed();
97
+ if (!entries) return;
98
+ // Accept Array<[path,value]>, Map, or plain object
99
+ batch(() => {
100
+ if (Array.isArray(entries)){
101
+ for (const [p, v] of entries) set(p, v);
102
+ } else if (entries instanceof Map){
103
+ for (const [p, v] of entries.entries()) set(p, v);
104
+ } else if (typeof entries === 'object'){
105
+ for (const p of Object.keys(entries)) set(p, entries[p]);
106
+ }
107
+ });
108
+ }
109
+
110
+ function destroy(){
111
+ if (destroyed) return;
112
+ // Best-effort unsubscribe of known subs created via this facade
113
+ for (const unsub of Array.from(_subscriptions)){
114
+ try { unsub(); } catch {}
115
+ _subscriptions.delete(unsub);
116
+ }
117
+ // Forward to base.destroy if present
118
+ if (typeof base.destroy === 'function'){
119
+ try { base.destroy(); } catch {}
120
+ }
121
+ destroyed = true;
122
+ // Drop buffered writes (safer than flushing after destroy)
123
+ buffer.clear();
124
+ }
125
+
126
+ return {
127
+ // Core parity
128
+ get,
129
+ set,
130
+ subscribe,
131
+ // Added ergonomics
132
+ off,
133
+ destroy,
134
+ // Batching utilities
135
+ batch,
136
+ setMany,
137
+ // Introspection
138
+ get destroyed(){ return destroyed; },
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Upgrade an existing base store into a Plus facade, without duplicating state.
144
+ * Accepts any object implementing { get, set, subscribe, destroy? }.
145
+ */
146
+ export function upgradeEventState(base){
147
+ // Wrap an existing store with the same facade used above. This avoids creating a new base.
148
+ // Reuse the createEventStatePlus mechanics but without constructing a new base store.
149
+ // Implementation mirrors createEventStatePlus, substituting `base` for the newly created one.
150
+
151
+ let destroyed = false;
152
+ const _subscriptions = new Set();
153
+ const assertNotDestroyed = () => { if (destroyed) throw new Error('EventState store has been destroyed'); };
154
+
155
+ let batching = false;
156
+ let buffer = new Map();
157
+ const flushBuffer = () => {
158
+ if (buffer.size === 0) return;
159
+ const entries = Array.from(buffer.entries());
160
+ buffer.clear();
161
+ for (const [path, value] of entries){ base.set(path, value); }
162
+ };
163
+ const batch = (fn) => {
164
+ assertNotDestroyed();
165
+ const wasBatching = batching;
166
+ batching = true;
167
+ try { fn(); } finally { batching = wasBatching; if (!batching) flushBuffer(); }
168
+ };
169
+
170
+ const get = (path) => base.get(path);
171
+ const set = (path, value) => { assertNotDestroyed(); if (!path) return value; if (batching){ buffer.set(path, value); return value; } return base.set(path, value); };
172
+ const subscribe = (path, handler) => {
173
+ assertNotDestroyed();
174
+ if (typeof path !== 'string' || typeof handler !== 'function') throw new TypeError('subscribe(path, handler) requires a string path and function handler');
175
+ const wrapped = (detail, subscribedPath) => { handler(detail, { type: subscribedPath, detail }); };
176
+ const unsubscribe = base.subscribe(path, wrapped);
177
+ _subscriptions.add(unsubscribe);
178
+ return function off(){ _subscriptions.delete(unsubscribe); return unsubscribe(); };
179
+ };
180
+ const off = (unsubscribe) => {
181
+ if (typeof unsubscribe !== 'function') throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
182
+ return unsubscribe();
183
+ };
184
+ const setMany = (entries) => {
185
+ assertNotDestroyed();
186
+ if (!entries) return;
187
+ batch(() => {
188
+ if (Array.isArray(entries)) for (const [p,v] of entries) set(p,v);
189
+ else if (entries instanceof Map) for (const [p,v] of entries.entries()) set(p,v);
190
+ else if (typeof entries === 'object') for (const p of Object.keys(entries)) set(p, entries[p]);
191
+ });
192
+ };
193
+ const destroy = () => {
194
+ if (destroyed) return;
195
+ for (const unsub of Array.from(_subscriptions)){
196
+ try { unsub(); } catch {}
197
+ _subscriptions.delete(unsub);
198
+ }
199
+ if (typeof base.destroy === 'function') { try { base.destroy(); } catch {} }
200
+ destroyed = true;
201
+ buffer.clear();
202
+ };
203
+
204
+ return {
205
+ get, set, subscribe, off, destroy, batch, setMany,
206
+ get destroyed(){ return destroyed; },
207
+ };
208
+ }
209
+
210
+ export default createEventStatePlus;
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // hydrate.js — general JSON→store utilities
3
+ // Minimal, framework-agnostic helpers that operate on a simple store interface:
4
+ // - store.get(path: string): any
5
+ // - store.set(path: string, value: any): void
6
+ // Options are defensive and production-oriented but remain tiny.
7
+
8
+ /**
9
+ * @typedef {Object} Store
10
+ * @property {(path:string)=>any} get
11
+ * @property {(path:string, value:any)=>void} set
12
+ * @property {(path:string, fn:(v:any)=>void)=>(()=>void)=} subscribe
13
+ */
14
+
15
+ /**
16
+ * Execute a function intended to perform multiple set() calls.
17
+ * Note: Without native store batching, this is a semantic wrapper.
18
+ * Consumers may swap this with a true batch if available.
19
+ *
20
+ * @template T
21
+ * @param {Store} store
22
+ * @param {() => T} fn
23
+ * @returns {T}
24
+ */
25
+ export function withBatch(store, fn) {
26
+ return fn();
27
+ }
28
+
29
+ function inWhitelist(path, whitelistPaths) {
30
+ if (!whitelistPaths || whitelistPaths.length === 0) return true;
31
+ return whitelistPaths.some((p) => path === p || path.startsWith(p + '.'));
32
+ }
33
+
34
+ /**
35
+ * Replace top-level subtrees.
36
+ *
37
+ * @param {Store} store
38
+ * @param {Record<string, any>} payload - e.g. { demo: {...}, ui: {...} }
39
+ * @param {Object} [opts]
40
+ * @param {string[]} [opts.whitelistPaths] - Allowed path prefixes (e.g., ['form'])
41
+ * @param {boolean} [opts.batch] - If true, wraps changes in {@link withBatch}
42
+ * @returns {void}
43
+ */
44
+ export function hydrateReplace(store, payload, opts = {}) {
45
+ const { whitelistPaths, batch } = opts;
46
+ const apply = () => {
47
+ for (const [k, v] of Object.entries(payload || {})) {
48
+ if (!inWhitelist(k, whitelistPaths)) continue;
49
+ store.set(k, v);
50
+ }
51
+ };
52
+ return batch ? withBatch(store, apply) : apply();
53
+ }
54
+
55
+ /**
56
+ * Deep merge a payload into the store, starting from a root path.
57
+ *
58
+ * Arrays: `replace` (default) or `keyedMerge` by a `keyField` (default: `id`).
59
+ * Conflicts: optional `onConflict(path, prev, next)` on leaf writes.
60
+ * Version: optional guard to avoid stale apply via `version` + `getVersion()`.
61
+ *
62
+ * @param {Store} store
63
+ * @param {string} root - Path prefix to merge under (e.g., 'demo')
64
+ * @param {any} payload - Arbitrary JSON-compatible structure
65
+ * @param {Object} [opts]
66
+ * @param {(path:string, prev:any, next:any)=>any} [opts.onConflict]
67
+ * @param {('replace'|'keyedMerge')} [opts.arrayStrategy]
68
+ * @param {string} [opts.keyField]
69
+ * @param {string[]} [opts.whitelistPaths]
70
+ * @param {number} [opts.version]
71
+ * @param {()=>number} [opts.getVersion]
72
+ * @param {boolean} [opts.batch]
73
+ * @returns {void}
74
+ */
75
+ export function hydrateMerge(store, root, payload, opts = {}) {
76
+ const {
77
+ onConflict,
78
+ arrayStrategy = 'replace',
79
+ keyField = 'id',
80
+ whitelistPaths,
81
+ version,
82
+ getVersion,
83
+ batch,
84
+ } = opts;
85
+
86
+ if (version != null && typeof getVersion === 'function') {
87
+ const current = getVersion();
88
+ if (current != null && version <= current) {
89
+ // Incoming is stale or equal; skip.
90
+ return;
91
+ }
92
+ }
93
+
94
+ const setLeaf = (path, nextVal) => {
95
+ if (!inWhitelist(path, whitelistPaths)) return;
96
+ const prev = store.get(path);
97
+ if (onConflict && typeof nextVal !== 'object') {
98
+ if (typeof prev !== 'undefined') {
99
+ const resolved = onConflict(path, prev, nextVal);
100
+ store.set(path, resolved);
101
+ return;
102
+ }
103
+ }
104
+ store.set(path, nextVal);
105
+ };
106
+
107
+ const mergeArray = (path, incomingArr) => {
108
+ if (arrayStrategy === 'replace') {
109
+ setLeaf(path, incomingArr);
110
+ return;
111
+ }
112
+ // keyedMerge
113
+ const local = store.get(path) || [];
114
+ const byKey = new Map();
115
+ for (const item of local) {
116
+ const k = item && typeof item === 'object' ? item[keyField] : undefined;
117
+ if (k != null) byKey.set(String(k), item);
118
+ }
119
+ const result = [...local];
120
+ for (const inc of incomingArr) {
121
+ const k = inc && typeof inc === 'object' ? inc[keyField] : undefined;
122
+ if (k == null) { result.push(inc); continue; }
123
+ const sk = String(k);
124
+ const existingIdx = result.findIndex((x) => x && x[keyField] != null && String(x[keyField]) === sk);
125
+ if (existingIdx >= 0) {
126
+ const old = result[existingIdx];
127
+ // shallow merge; objects only
128
+ if (old && typeof old === 'object' && inc && typeof inc === 'object') {
129
+ result[existingIdx] = { ...old, ...inc };
130
+ } else {
131
+ result[existingIdx] = inc;
132
+ }
133
+ } else {
134
+ result.push(inc);
135
+ }
136
+ }
137
+ setLeaf(path, result);
138
+ };
139
+
140
+ const recurse = (prefix, value) => {
141
+ if (Array.isArray(value)) {
142
+ mergeArray(prefix, value);
143
+ return;
144
+ }
145
+ if (value && typeof value === 'object') {
146
+ for (const [k, v] of Object.entries(value)) {
147
+ const path = prefix ? `${prefix}.${k}` : k;
148
+ recurse(path, v);
149
+ }
150
+ } else {
151
+ setLeaf(prefix, value);
152
+ }
153
+ };
154
+
155
+ const apply = () => recurse(root || '', payload);
156
+ return batch ? withBatch(store, apply) : apply();
157
+ }
@@ -0,0 +1,69 @@
1
+ // queryBinding.js — bind a URL query param to a store path (two-way, minimal loops)
2
+ // Usage:
3
+ // import { bindQueryParam } from './queryBinding.js';
4
+ // const unbind = bindQueryParam(store, { param: 'tab', path: 'ui.nav.tab', coerce: (v)=>v });
5
+ // Notes:
6
+ // - Reads current ui.route.query on navigation and applies to `path`.
7
+ // - When the store path changes, updates the URL query via history.replaceState.
8
+ // - Avoids re-entrancy loops with a tiny guard.
9
+
10
+ export function bindQueryParam(store, { param, path, coerce, defaultValue, omitDefault, mode = 'replace' } = {}){
11
+ if (!param || !path) throw new Error('bindQueryParam: `param` and `path` are required');
12
+ let internalUpdate = false;
13
+ let lastValue; // track last store value to drive smart push/replace
14
+
15
+ // Apply query -> store on any route query change
16
+ const applyFromQuery = () => {
17
+ const q = store.get('ui.route.query') || {};
18
+ const raw = q[param];
19
+ let val = typeof coerce === 'function' ? coerce(raw) : raw;
20
+ if (typeof raw === 'undefined' && typeof defaultValue !== 'undefined') {
21
+ val = defaultValue;
22
+ }
23
+ internalUpdate = true;
24
+ try { if (typeof val !== 'undefined') store.set(path, val); }
25
+ finally { internalUpdate = false; }
26
+ lastValue = store.get(path);
27
+ };
28
+
29
+ // Apply store -> URL on path change
30
+ const applyToUrl = () => {
31
+ if (internalUpdate) return;
32
+ const current = store.get(path);
33
+ // Build next query object from ui.route.query, then set/clear our param
34
+ const q = Object.assign({}, store.get('ui.route.query') || {});
35
+ const isDefault = omitDefault && typeof defaultValue !== 'undefined' && current === defaultValue;
36
+ if (typeof current === 'undefined' || current === null || current === '' || isDefault) delete q[param];
37
+ else q[param] = String(current);
38
+ // Write new query into URL (replaceState), and reflect into store.ui.route.query
39
+ const p = store.get('ui.route.path') || location.pathname;
40
+ const sp = new URLSearchParams(q);
41
+ const next = p + (sp.toString() ? ('?' + sp.toString()) : '') + location.hash;
42
+ // Decide push vs replace per mode
43
+ let doPush = false;
44
+ if (mode === 'push') doPush = true;
45
+ else if (mode === 'replace') doPush = false;
46
+ else if (mode === 'smart') {
47
+ const prev = lastValue;
48
+ if (isDefault) doPush = false; // returning to default cleans URL
49
+ else if (typeof prev === 'undefined') doPush = true; // first set
50
+ else if (prev !== current) doPush = true; // switching between non-defaults
51
+ else doPush = false;
52
+ }
53
+ if (doPush) history.pushState({}, '', next); else history.replaceState({}, '', next);
54
+ // Reflect new query map into store without recursion back to `path`
55
+ internalUpdate = true;
56
+ try { store.set('ui.route.query', Object.fromEntries(sp.entries())); }
57
+ finally { internalUpdate = false; }
58
+ lastValue = current;
59
+ };
60
+
61
+ // Subscriptions
62
+ const unsubA = store.subscribe('ui.route.query', applyFromQuery);
63
+ const unsubB = store.subscribe(path, applyToUrl);
64
+
65
+ // Initial sync from query
66
+ applyFromQuery();
67
+
68
+ return () => { unsubA(); unsubB(); };
69
+ }
@@ -0,0 +1,78 @@
1
+ // forms/computed.js — explicit-deps computed helper for eventState-like stores
2
+ // Requirements for store: get(path), set(path, value), subscribe(path, handler)
3
+ // Features: explicit deps, loop-avoidance, optional debounce, optional memo by dep tuple, optional gatePath, immediate compute
4
+
5
+ import { withBatch } from '../extensions/hydrate.js';
6
+
7
+ function tupleKey(vals) {
8
+ // Simple, deterministic key for small tuples
9
+ try { return JSON.stringify(vals); } catch { return String(vals); }
10
+ }
11
+
12
+ /**
13
+ * Register a computed field derived from explicit dependencies.
14
+ * @param {any} store
15
+ * @param {string} targetPath
16
+ * @param {string[]} deps
17
+ * @param {(get:(p:string)=>any)=>any} fn
18
+ * @param {{ debounce?: number, memo?: boolean, immediate?: boolean, gatePath?: string }} options
19
+ * @returns {() => void} unsubscribe function
20
+ */
21
+ export function computed(store, targetPath, deps, fn, options = {}) {
22
+ const { debounce = 0, memo = false, immediate = true, gatePath } = options;
23
+ let timer = null;
24
+ let lastKey = undefined;
25
+ let destroyed = false;
26
+
27
+ const get = (p) => store.get(p);
28
+
29
+ const doCompute = () => {
30
+ if (destroyed) return;
31
+ if (gatePath) {
32
+ const gateVal = store.get(gatePath);
33
+ if (!gateVal) return; // gate closed
34
+ }
35
+ const depVals = deps.map((d) => store.get(d));
36
+ if (memo) {
37
+ const k = tupleKey(depVals);
38
+ if (k === lastKey) return;
39
+ lastKey = k;
40
+ }
41
+ const nextVal = fn(get);
42
+ // Loop avoidance: only write if value actually changed (best-effort)
43
+ const prev = store.get(targetPath);
44
+ if (prev !== nextVal) {
45
+ withBatch(store, () => {
46
+ store.set(targetPath, nextVal);
47
+ });
48
+ }
49
+ };
50
+
51
+ const schedule = () => {
52
+ if (debounce > 0) {
53
+ if (timer) clearTimeout(timer);
54
+ timer = setTimeout(doCompute, debounce);
55
+ } else {
56
+ doCompute();
57
+ }
58
+ };
59
+
60
+ const unsubs = deps.map((d) => store.subscribe(d, () => schedule()));
61
+ // Ignore self-updates if someone else sets targetPath explicitly
62
+ const unsubSelf = store.subscribe(targetPath, () => {
63
+ // no-op; presence prevents some stores from GC-ing path observers
64
+ });
65
+ // If a gate is used, subscribe to it so opening/closing the gate retriggers compute
66
+ if (gatePath) {
67
+ unsubs.push(store.subscribe(gatePath, () => schedule()));
68
+ }
69
+
70
+ if (immediate) schedule();
71
+
72
+ return () => {
73
+ destroyed = true;
74
+ if (timer) { try { clearTimeout(timer); } catch {} }
75
+ for (const u of unsubs) { try { u && u(); } catch {} }
76
+ try { unsubSelf && unsubSelf(); } catch {}
77
+ };
78
+ }
@@ -0,0 +1,51 @@
1
+ // forms/meta.js — touched/dirty helpers and simple a11y reflection
2
+ // Generic, path-agnostic helpers. Caller passes concrete value/meta paths.
3
+
4
+ /**
5
+ * Initialize field meta with an initial value.
6
+ * @param {any} store
7
+ * @param {{ valuePath: string, metaPath: string, initialValue?: any }} opts
8
+ */
9
+ export function initFieldMeta(store, { valuePath, metaPath, initialValue }) {
10
+ const init = (typeof initialValue !== 'undefined') ? initialValue : store.get(valuePath);
11
+ store.set(`${metaPath}.initial`, init);
12
+ store.set(`${metaPath}.touched`, false);
13
+ store.set(`${metaPath}.dirty`, false);
14
+ store.set(`${metaPath}.invalid`, false);
15
+ }
16
+
17
+ /** Mark as touched (e.g., on blur) */
18
+ export function markTouched(store, metaPath) {
19
+ store.set(`${metaPath}.touched`, true);
20
+ }
21
+
22
+ /** Update dirty flag by comparing current value to initial */
23
+ export function updateDirty(store, { valuePath, metaPath }) {
24
+ const current = store.get(valuePath);
25
+ const initial = store.get(`${metaPath}.initial`);
26
+ store.set(`${metaPath}.dirty`, current !== initial);
27
+ }
28
+
29
+ /** Reset meta flags (preserves initial) */
30
+ export function resetFieldMeta(store, metaPath) {
31
+ store.set(`${metaPath}.touched`, false);
32
+ store.set(`${metaPath}.dirty`, false);
33
+ store.set(`${metaPath}.invalid`, false);
34
+ }
35
+
36
+ /** Reset value to initial and clear flags */
37
+ export function resetToInitial(store, { valuePath, metaPath }) {
38
+ const initial = store.get(`${metaPath}.initial`);
39
+ store.set(valuePath, initial);
40
+ resetFieldMeta(store, metaPath);
41
+ }
42
+
43
+ /**
44
+ * Reflect invalid based on errorsByField[fieldKey].length
45
+ * @param {any} store
46
+ * @param {{ metaPath: string, errorsByField: Record<string, string[]>, fieldKey: string }} opts
47
+ */
48
+ export function reflectInvalid(store, { metaPath, errorsByField, fieldKey }) {
49
+ const invalid = Array.isArray(errorsByField?.[fieldKey]) && errorsByField[fieldKey].length > 0;
50
+ store.set(`${metaPath}.invalid`, invalid);
51
+ }
@@ -0,0 +1,28 @@
1
+ // forms/submitWithBoundary.js — thin wrapper over runWithBoundary for forms
2
+ // Usage: submitWithBoundary(store, asyncFn, { submittingPath, errorPath, successPath })
3
+ // Ensures submitting flag toggles, errors are mapped to a path, and optional success payload is written.
4
+
5
+ import { runWithBoundary } from '../extensions/boundary.js';
6
+
7
+ /**
8
+ * @template T
9
+ * @param {any} store - eventState-like store with get/set
10
+ * @param {() => Promise<T>} fn - async submit function
11
+ * @param {{ submittingPath: string, errorPath?: string, successPath?: string, mapError?: (e:any)=>any }} opts
12
+ * @returns {Promise<T|any|undefined>}
13
+ */
14
+ export function submitWithBoundary(store, fn, opts) {
15
+ const { submittingPath, errorPath, successPath, mapError } = opts || {};
16
+ if (submittingPath) store.set(submittingPath, true);
17
+ if (errorPath) store.set(errorPath, null);
18
+
19
+ return runWithBoundary(fn, {
20
+ setLoading: (b) => { if (submittingPath) store.set(submittingPath, b); },
21
+ onError: (err) => { if (errorPath) store.set(errorPath, err); },
22
+ mapError,
23
+ finally: () => {}
24
+ }).then((res) => {
25
+ if (typeof successPath === 'string') store.set(successPath, res ?? null);
26
+ return res;
27
+ });
28
+ }
@@ -0,0 +1,55 @@
1
+ // forms/validators.js — minimal sync/async validation helper
2
+ // Shape:
3
+ // validate(model, rules) -> Promise<{ valid: boolean, errorsByField: Record<string,string[]>, errorsGlobal: string[] }>
4
+ // Rules shape:
5
+ // {
6
+ // fieldName: [ ruleFn, ... ],
7
+ // _global?: [ ruleFn, ... ]
8
+ // }
9
+ // ruleFn signature:
10
+ // (value, model) => string|undefined|Promise<string|undefined>
11
+ // Returns a message when invalid; undefined when valid.
12
+
13
+ /**
14
+ * @param {any} model
15
+ * @param {Record<string, Array<Function>>} rules
16
+ */
17
+ export async function validate(model, rules = {}) {
18
+ const errorsByField = {};
19
+ const errorsGlobal = [];
20
+
21
+ const entries = Object.entries(rules).filter(([k]) => k !== '_global');
22
+
23
+ for (const [field, fns] of entries) {
24
+ const val = field.split('.').reduce((acc, k) => (acc ? acc[k] : undefined), model);
25
+ for (const fn of fns || []) {
26
+ const res = await fn(val, model);
27
+ if (typeof res === 'string' && res) {
28
+ (errorsByField[field] ||= []).push(res);
29
+ }
30
+ }
31
+ }
32
+
33
+ const globals = rules._global || [];
34
+ for (const fn of globals) {
35
+ const res = await fn(model);
36
+ if (typeof res === 'string' && res) errorsGlobal.push(res);
37
+ }
38
+
39
+ const valid = Object.keys(errorsByField).length === 0 && errorsGlobal.length === 0;
40
+ return { valid, errorsByField, errorsGlobal };
41
+ }
42
+
43
+ // Some tiny reusable rules
44
+ export const Rules = {
45
+ required: (msg = 'This field is required.') => (v) => (v == null || v === '' ? msg : undefined),
46
+ minLen: (n, msg) => (v) => (typeof v === 'string' && v.length < n ? (msg || `Must be at least ${n} characters.`) : undefined),
47
+ pattern: (re, msg) => (v) => (v && !re.test(String(v)) ? (msg || 'Invalid format.') : undefined),
48
+ email: (msg = 'Enter a valid email.') => (v) => (!v ? undefined : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v)) ? undefined : msg),
49
+ // Async example: simulate uniqueness check
50
+ asyncUnique: (checkFn, msg = 'Already taken.') => async (v) => {
51
+ if (!v) return undefined;
52
+ const ok = await checkFn(v);
53
+ return ok ? undefined : msg;
54
+ },
55
+ };
@@ -0,0 +1,23 @@
1
+ // Auto-generated from test assertions
2
+ // DO NOT EDIT - regenerate by running: node tests/generateTypes.js
3
+
4
+ export interface StoreState {
5
+ domain: {
6
+ todos: {
7
+ items: Array<{ id: number; text: string; done: boolean }>;
8
+ };
9
+ };
10
+ ui: {
11
+ todos: {
12
+ filter: string;
13
+ };
14
+ };
15
+ intent: {
16
+ todo: {
17
+ add: { text: string };
18
+ toggle: { id: number };
19
+ };
20
+ };
21
+ }
22
+
23
+ export default StoreState;