@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.
- package/README.md +40 -0
- package/cssState/.gitkeep +0 -0
- package/eventState/001-counter/README.md +44 -0
- package/eventState/001-counter/index.html +33 -0
- package/eventState/002-counter-improved/README.md +44 -0
- package/eventState/002-counter-improved/index.html +47 -0
- package/eventState/003-input-reactive/README.md +44 -0
- package/eventState/003-input-reactive/index.html +33 -0
- package/eventState/004-computed-state/README.md +45 -0
- package/eventState/004-computed-state/index.html +65 -0
- package/eventState/005-conditional-rendering/README.md +42 -0
- package/eventState/005-conditional-rendering/index.html +39 -0
- package/eventState/006-list-rendering/README.md +49 -0
- package/eventState/006-list-rendering/index.html +63 -0
- package/eventState/007-form-validation/README.md +52 -0
- package/eventState/007-form-validation/index.html +102 -0
- package/eventState/008-undo-redo/README.md +70 -0
- package/eventState/008-undo-redo/index.html +108 -0
- package/eventState/009-localStorage-side-effects/README.md +72 -0
- package/eventState/009-localStorage-side-effects/index.html +57 -0
- package/eventState/010-decoupled-components/README.md +74 -0
- package/eventState/010-decoupled-components/index.html +93 -0
- package/eventState/011-async-patterns/README.md +98 -0
- package/eventState/011-async-patterns/index.html +132 -0
- package/eventState/028-counter-improved-eventTest/LICENSE +55 -0
- package/eventState/028-counter-improved-eventTest/README.md +131 -0
- package/eventState/028-counter-improved-eventTest/app/store.js +9 -0
- package/eventState/028-counter-improved-eventTest/index.html +49 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/028-counter-improved-eventTest/runtime/core/router.js +271 -0
- package/eventState/028-counter-improved-eventTest/store.d.ts +8 -0
- package/eventState/028-counter-improved-eventTest/style.css +170 -0
- package/eventState/028-counter-improved-eventTest/tests/README.md +208 -0
- package/eventState/028-counter-improved-eventTest/tests/counter.test.js +116 -0
- package/eventState/028-counter-improved-eventTest/tests/eventTest.js +176 -0
- package/eventState/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
- package/eventState/028-counter-improved-eventTest/tests/run.js +20 -0
- package/eventState/030-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/030-todo-app-with-eventTest/README.md +121 -0
- package/eventState/030-todo-app-with-eventTest/app/router.js +25 -0
- package/eventState/030-todo-app-with-eventTest/app/store.js +16 -0
- package/eventState/030-todo-app-with-eventTest/app/views/home.js +11 -0
- package/eventState/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
- package/eventState/030-todo-app-with-eventTest/index.html +65 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/030-todo-app-with-eventTest/store.d.ts +18 -0
- package/eventState/030-todo-app-with-eventTest/style.css +170 -0
- package/eventState/030-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
- package/eventState/030-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
- package/eventState/031-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/031-todo-app-with-eventTest/README.md +54 -0
- package/eventState/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/eventState/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/eventState/031-todo-app-with-eventTest/app/bridges.js +113 -0
- package/eventState/031-todo-app-with-eventTest/app/router.js +26 -0
- package/eventState/031-todo-app-with-eventTest/app/store.js +15 -0
- package/eventState/031-todo-app-with-eventTest/app/views/home.js +46 -0
- package/eventState/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/eventState/031-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/eventState/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/eventState/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/eventState/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/eventState/031-todo-app-with-eventTest/index.html +103 -0
- package/eventState/031-todo-app-with-eventTest/package-lock.json +2184 -0
- package/eventState/031-todo-app-with-eventTest/package.json +24 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/eventState/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/eventState/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/eventState/031-todo-app-with-eventTest/store.d.ts +23 -0
- package/eventState/031-todo-app-with-eventTest/style.css +170 -0
- package/eventState/031-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/eventState/031-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/eventState/032-todo-app-with-eventTest/LICENSE +55 -0
- package/eventState/032-todo-app-with-eventTest/README.md +54 -0
- package/eventState/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/eventState/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/eventState/032-todo-app-with-eventTest/app/actions/index.js +153 -0
- package/eventState/032-todo-app-with-eventTest/app/bridges.js +113 -0
- package/eventState/032-todo-app-with-eventTest/app/router.js +26 -0
- package/eventState/032-todo-app-with-eventTest/app/store.js +15 -0
- package/eventState/032-todo-app-with-eventTest/app/views/home.js +46 -0
- package/eventState/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/eventState/032-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/eventState/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/eventState/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/eventState/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/eventState/032-todo-app-with-eventTest/index.html +87 -0
- package/eventState/032-todo-app-with-eventTest/package-lock.json +2184 -0
- package/eventState/032-todo-app-with-eventTest/package.json +24 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/eventStateNew.js +149 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/eventState/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/eventState/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/eventState/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/eventState/032-todo-app-with-eventTest/store.d.ts +23 -0
- package/eventState/032-todo-app-with-eventTest/style.css +170 -0
- package/eventState/032-todo-app-with-eventTest/tests/README.md +208 -0
- package/eventState/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/eventState/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/eventState/032-todo-app-with-eventTest/tests/run.js +20 -0
- package/eventState/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
- 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;
|