@uistate/core 4.1.2 → 5.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/LICENSE-eventTest.md +26 -0
- package/README.md +409 -42
- package/cssState.js +32 -1
- package/eventState.js +58 -49
- package/eventTest.js +196 -0
- package/examples/001-counter/README.md +44 -0
- package/examples/001-counter/eventState.js +86 -0
- package/examples/001-counter/index.html +30 -0
- package/examples/002-counter-improved/README.md +44 -0
- package/examples/002-counter-improved/eventState.js +86 -0
- package/examples/002-counter-improved/index.html +44 -0
- package/examples/003-input-reactive/README.md +44 -0
- package/examples/003-input-reactive/eventState.js +86 -0
- package/examples/003-input-reactive/index.html +30 -0
- package/examples/004-computed-state/README.md +45 -0
- package/examples/004-computed-state/eventState.js +86 -0
- package/examples/004-computed-state/index.html +62 -0
- package/examples/005-conditional-rendering/README.md +42 -0
- package/examples/005-conditional-rendering/eventState.js +86 -0
- package/examples/005-conditional-rendering/index.html +36 -0
- package/examples/006-list-rendering/README.md +49 -0
- package/examples/006-list-rendering/eventState.js +86 -0
- package/examples/006-list-rendering/index.html +60 -0
- package/examples/007-form-validation/README.md +52 -0
- package/examples/007-form-validation/eventState.js +86 -0
- package/examples/007-form-validation/index.html +99 -0
- package/examples/008-undo-redo/README.md +70 -0
- package/examples/008-undo-redo/eventState.js +86 -0
- package/examples/008-undo-redo/index.html +105 -0
- package/examples/009-localStorage-side-effects/README.md +72 -0
- package/examples/009-localStorage-side-effects/eventState.js +86 -0
- package/examples/009-localStorage-side-effects/index.html +54 -0
- package/examples/010-decoupled-components/README.md +74 -0
- package/examples/010-decoupled-components/eventState.js +86 -0
- package/examples/010-decoupled-components/index.html +90 -0
- package/examples/011-async-patterns/README.md +98 -0
- package/examples/011-async-patterns/eventState.js +86 -0
- package/examples/011-async-patterns/index.html +129 -0
- package/examples/028-counter-improved-eventTest/LICENSE +55 -0
- package/examples/028-counter-improved-eventTest/README.md +131 -0
- package/examples/028-counter-improved-eventTest/app/store.js +9 -0
- package/examples/028-counter-improved-eventTest/index.html +49 -0
- package/examples/028-counter-improved-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/028-counter-improved-eventTest/runtime/core/eventState.js +100 -0
- package/examples/028-counter-improved-eventTest/runtime/core/helpers.js +212 -0
- package/examples/028-counter-improved-eventTest/runtime/core/router.js +271 -0
- package/examples/028-counter-improved-eventTest/store.d.ts +8 -0
- package/examples/028-counter-improved-eventTest/style.css +170 -0
- package/examples/028-counter-improved-eventTest/tests/README.md +208 -0
- package/examples/028-counter-improved-eventTest/tests/counter.test.js +116 -0
- package/examples/028-counter-improved-eventTest/tests/eventTest.js +176 -0
- package/examples/028-counter-improved-eventTest/tests/generateTypes.js +168 -0
- package/examples/028-counter-improved-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/030-todo-app-with-eventTest/README.md +121 -0
- package/examples/030-todo-app-with-eventTest/app/router.js +25 -0
- package/examples/030-todo-app-with-eventTest/app/store.js +16 -0
- package/examples/030-todo-app-with-eventTest/app/views/home.js +11 -0
- package/examples/030-todo-app-with-eventTest/app/views/todoDemo.js +88 -0
- package/examples/030-todo-app-with-eventTest/index.html +65 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/030-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/030-todo-app-with-eventTest/store.d.ts +18 -0
- package/examples/030-todo-app-with-eventTest/style.css +170 -0
- package/examples/030-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/030-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/030-todo-app-with-eventTest/tests/generateTypes.js +189 -0
- package/examples/030-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/030-todo-app-with-eventTest/tests/todos.test.js +167 -0
- package/examples/031-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/031-todo-app-with-eventTest/README.md +54 -0
- package/examples/031-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/031-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/031-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/031-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/031-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/031-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/031-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/031-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/031-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/031-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/031-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/031-todo-app-with-eventTest/index.html +103 -0
- package/examples/031-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/031-todo-app-with-eventTest/package.json +24 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/031-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/031-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/031-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/031-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/031-todo-app-with-eventTest/style.css +170 -0
- package/examples/031-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/031-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/031-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/031-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/031-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/examples/032-todo-app-with-eventTest/LICENSE +55 -0
- package/examples/032-todo-app-with-eventTest/README.md +54 -0
- package/examples/032-todo-app-with-eventTest/TUTORIAL.md +390 -0
- package/examples/032-todo-app-with-eventTest/WHY_EVENTSTATE.md +777 -0
- package/examples/032-todo-app-with-eventTest/app/actions/index.js +153 -0
- package/examples/032-todo-app-with-eventTest/app/bridges.js +113 -0
- package/examples/032-todo-app-with-eventTest/app/router.js +26 -0
- package/examples/032-todo-app-with-eventTest/app/store.js +15 -0
- package/examples/032-todo-app-with-eventTest/app/views/home.js +46 -0
- package/examples/032-todo-app-with-eventTest/app/views/todoDemo.js +69 -0
- package/examples/032-todo-app-with-eventTest/devtools/dock.js +41 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.dock.js +10 -0
- package/examples/032-todo-app-with-eventTest/devtools/stateTracker.js +246 -0
- package/examples/032-todo-app-with-eventTest/devtools/telemetry.js +104 -0
- package/examples/032-todo-app-with-eventTest/devtools/typeGenerator.js +339 -0
- package/examples/032-todo-app-with-eventTest/index.html +87 -0
- package/examples/032-todo-app-with-eventTest/package-lock.json +2184 -0
- package/examples/032-todo-app-with-eventTest/package.json +24 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/behaviors.runtime.js +282 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/eventState.js +100 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/helpers.js +212 -0
- package/examples/032-todo-app-with-eventTest/runtime/core/router.js +271 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/boundary.js +36 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/converge.js +63 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/eventState.plus.js +210 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/hydrate.js +157 -0
- package/examples/032-todo-app-with-eventTest/runtime/extensions/queryBinding.js +69 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/computed.js +78 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/meta.js +51 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/submitWithBoundary.js +28 -0
- package/examples/032-todo-app-with-eventTest/runtime/forms/validators.js +55 -0
- package/examples/032-todo-app-with-eventTest/store.d.ts +23 -0
- package/examples/032-todo-app-with-eventTest/style.css +170 -0
- package/examples/032-todo-app-with-eventTest/tests/README.md +208 -0
- package/examples/032-todo-app-with-eventTest/tests/eventTest.js +176 -0
- package/examples/032-todo-app-with-eventTest/tests/generateTypes.js +191 -0
- package/examples/032-todo-app-with-eventTest/tests/run.js +20 -0
- package/examples/032-todo-app-with-eventTest/tests/todos.test.js +192 -0
- package/index.js +14 -3
- package/package.json +16 -7
- package/stateSerializer.js +99 -4
- package/templateManager.js +50 -2
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// runtime/core/router.js — Generic SPA router factory for eventState stores
|
|
2
|
+
// Usage:
|
|
3
|
+
// const router = createRouter({
|
|
4
|
+
// routes: [{ path: '/', view: 'home', component: HomeView }],
|
|
5
|
+
// store,
|
|
6
|
+
// rootSelector: '[data-route-root]',
|
|
7
|
+
// debug: true
|
|
8
|
+
// });
|
|
9
|
+
// router.start();
|
|
10
|
+
|
|
11
|
+
export function createRouter(config) {
|
|
12
|
+
const {
|
|
13
|
+
routes = [],
|
|
14
|
+
store,
|
|
15
|
+
rootSelector = '[data-route-root]',
|
|
16
|
+
fallback = null,
|
|
17
|
+
debug = false,
|
|
18
|
+
linkSelector = 'a[data-link]',
|
|
19
|
+
navSelector = 'nav a[data-link]',
|
|
20
|
+
} = config;
|
|
21
|
+
|
|
22
|
+
// Detect base path from <base href> if present
|
|
23
|
+
const BASE_PATH = (() => {
|
|
24
|
+
const b = document.querySelector('base[href]');
|
|
25
|
+
if (!b) return '';
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(b.getAttribute('href'), location.href);
|
|
28
|
+
let p = u.pathname;
|
|
29
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
30
|
+
return p;
|
|
31
|
+
} catch { return ''; }
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
function stripBase(pathname) {
|
|
35
|
+
if (BASE_PATH && pathname.startsWith(BASE_PATH)) {
|
|
36
|
+
const rest = pathname.slice(BASE_PATH.length) || '/';
|
|
37
|
+
return rest.startsWith('/') ? rest : ('/' + rest);
|
|
38
|
+
}
|
|
39
|
+
return pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function withBase(pathname) {
|
|
43
|
+
if (!BASE_PATH) return pathname;
|
|
44
|
+
if (pathname === '/') return BASE_PATH || '/';
|
|
45
|
+
return (BASE_PATH + (pathname.startsWith('/') ? '' : '/') + pathname);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizePath(p) {
|
|
49
|
+
if (!p) return '/';
|
|
50
|
+
try {
|
|
51
|
+
if (p[0] !== '/') p = '/' + p;
|
|
52
|
+
if (p === '/index.html') return '/';
|
|
53
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
54
|
+
return p;
|
|
55
|
+
} catch { return '/'; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolve(pathname) {
|
|
59
|
+
const p = normalizePath(pathname);
|
|
60
|
+
const r = routes.find(r => r.path === p);
|
|
61
|
+
if (r) return { ...r, params: {} };
|
|
62
|
+
if (fallback) return { ...fallback, params: {} };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getRoot() {
|
|
67
|
+
const el = document.querySelector(rootSelector);
|
|
68
|
+
if (!el) throw new Error('Route root not found: ' + rootSelector);
|
|
69
|
+
return el;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setActiveNav(pathname) {
|
|
73
|
+
document.querySelectorAll(navSelector).forEach(a => {
|
|
74
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
75
|
+
const linkPath = normalizePath(stripBase(url.pathname));
|
|
76
|
+
const here = normalizePath(pathname);
|
|
77
|
+
const isExact = linkPath === here;
|
|
78
|
+
const isParent = !isExact && linkPath !== '/' && here.startsWith(linkPath);
|
|
79
|
+
const active = isExact || isParent;
|
|
80
|
+
a.classList.toggle('active', active);
|
|
81
|
+
if (isExact) a.setAttribute('aria-current', 'page');
|
|
82
|
+
else a.removeAttribute('aria-current');
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// State
|
|
87
|
+
let current = { viewKey: null, unboot: null, path: null, search: '' };
|
|
88
|
+
let navController = null;
|
|
89
|
+
const scrollPositions = new Map();
|
|
90
|
+
history.scrollRestoration = 'manual';
|
|
91
|
+
|
|
92
|
+
// Core navigate function
|
|
93
|
+
async function navigate(pathname, { replace = false, search = '', restoreScroll = false } = {}) {
|
|
94
|
+
const root = getRoot();
|
|
95
|
+
const appPath = normalizePath(stripBase(pathname));
|
|
96
|
+
const resolved = resolve(appPath);
|
|
97
|
+
|
|
98
|
+
if (!resolved) {
|
|
99
|
+
if (debug) console.warn('[router] No route found for:', appPath);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const viewKey = resolved.view;
|
|
104
|
+
const component = resolved.component;
|
|
105
|
+
const html = document.documentElement;
|
|
106
|
+
const prevViewKey = current.viewKey;
|
|
107
|
+
|
|
108
|
+
const searchStr = search && search.startsWith('?') ? search : (search ? ('?' + search) : '');
|
|
109
|
+
|
|
110
|
+
// Always log navigation for telemetry
|
|
111
|
+
console.log('[nav] navigate', { from: current.path, to: appPath, view: viewKey });
|
|
112
|
+
|
|
113
|
+
if (debug) {
|
|
114
|
+
console.debug('[router] navigate', { pathname, appPath, searchStr, view: viewKey, params: resolved.params });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Same-route no-op guard
|
|
118
|
+
if (current.path === appPath && current.search === searchStr) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Abort in-flight boot
|
|
123
|
+
if (navController) navController.abort();
|
|
124
|
+
navController = new AbortController();
|
|
125
|
+
const { signal } = navController;
|
|
126
|
+
|
|
127
|
+
// Transition start
|
|
128
|
+
if (store) {
|
|
129
|
+
try { store.set('ui.route.transitioning', true); } catch {}
|
|
130
|
+
}
|
|
131
|
+
html.setAttribute('data-transitioning', 'on');
|
|
132
|
+
|
|
133
|
+
// Save scroll position
|
|
134
|
+
if (current.path) {
|
|
135
|
+
scrollPositions.set(current.path, { x: scrollX, y: scrollY });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Unboot previous view
|
|
139
|
+
if (typeof current.unboot === 'function') {
|
|
140
|
+
try { await current.unboot(); } catch (_e) {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clear DOM
|
|
144
|
+
root.replaceChildren();
|
|
145
|
+
|
|
146
|
+
// Boot new view
|
|
147
|
+
const unboot = await (component.boot?.({ store, el: root, signal }) || Promise.resolve(() => {}));
|
|
148
|
+
current = { viewKey, unboot, path: appPath, search: searchStr };
|
|
149
|
+
|
|
150
|
+
// Update nav active state
|
|
151
|
+
setActiveNav(appPath);
|
|
152
|
+
|
|
153
|
+
// Parse query params
|
|
154
|
+
const urlForQuery = new URL(location.origin + withBase(appPath) + searchStr);
|
|
155
|
+
const q = {};
|
|
156
|
+
urlForQuery.searchParams.forEach((v, k) => { q[k] = v; });
|
|
157
|
+
|
|
158
|
+
// Update store
|
|
159
|
+
if (store) {
|
|
160
|
+
try {
|
|
161
|
+
if (store.setMany) {
|
|
162
|
+
store.setMany({
|
|
163
|
+
'ui.route.view': viewKey,
|
|
164
|
+
'ui.route.path': appPath,
|
|
165
|
+
'ui.route.params': resolved.params || {},
|
|
166
|
+
'ui.route.query': q,
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
store.set('ui.route.view', viewKey);
|
|
170
|
+
store.set('ui.route.path', appPath);
|
|
171
|
+
store.set('ui.route.params', resolved.params || {});
|
|
172
|
+
store.set('ui.route.query', q);
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Update browser history
|
|
178
|
+
const useReplace = replace || (prevViewKey === viewKey);
|
|
179
|
+
if (useReplace) history.replaceState({}, '', withBase(appPath) + searchStr);
|
|
180
|
+
else history.pushState({}, '', withBase(appPath) + searchStr);
|
|
181
|
+
|
|
182
|
+
// Set view attribute
|
|
183
|
+
html.setAttribute('data-view', viewKey);
|
|
184
|
+
|
|
185
|
+
if (debug) {
|
|
186
|
+
console.debug('[router] view', { viewKey, path: appPath, query: q });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Transition end
|
|
190
|
+
if (store) {
|
|
191
|
+
try { store.set('ui.route.transitioning', false); } catch {}
|
|
192
|
+
}
|
|
193
|
+
html.setAttribute('data-transitioning', 'off');
|
|
194
|
+
|
|
195
|
+
// Focus management
|
|
196
|
+
if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '-1');
|
|
197
|
+
try { root.focus({ preventScroll: true }); } catch {}
|
|
198
|
+
|
|
199
|
+
// Scroll restoration
|
|
200
|
+
if (restoreScroll) {
|
|
201
|
+
const pos = scrollPositions.get(appPath);
|
|
202
|
+
if (pos) scrollTo(pos.x, pos.y);
|
|
203
|
+
} else {
|
|
204
|
+
scrollTo(0, 0);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function navigateQuery(patch = {}, { replace = true } = {}) {
|
|
209
|
+
const params = new URLSearchParams(current.search || '');
|
|
210
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
211
|
+
if (v === null || v === undefined || v === '') params.delete(k);
|
|
212
|
+
else params.set(k, String(v));
|
|
213
|
+
}
|
|
214
|
+
const searchStr = params.toString();
|
|
215
|
+
const prefixed = searchStr ? ('?' + searchStr) : '';
|
|
216
|
+
const path = current.path || normalizePath(stripBase(location.pathname));
|
|
217
|
+
return navigate(path, { search: prefixed, replace });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function navigatePath(path, { replace = true } = {}) {
|
|
221
|
+
const appPath = normalizePath(stripBase(path));
|
|
222
|
+
const searchStr = current.search || '';
|
|
223
|
+
return navigate(appPath, { search: searchStr, replace });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onClick(e) {
|
|
227
|
+
const a = e.target.closest(linkSelector);
|
|
228
|
+
if (!a) return;
|
|
229
|
+
if (e.defaultPrevented) return;
|
|
230
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
231
|
+
const url = new URL(a.getAttribute('href'), location.href);
|
|
232
|
+
if (url.origin !== location.origin) return;
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
|
|
235
|
+
// Log navigation click
|
|
236
|
+
console.log('[nav] click', { href: a.getAttribute('href'), text: a.textContent.trim() });
|
|
237
|
+
|
|
238
|
+
navigate(url.pathname, { search: url.search }).catch(() => {});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function onPop() {
|
|
242
|
+
navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true }).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Public API
|
|
246
|
+
return {
|
|
247
|
+
navigate,
|
|
248
|
+
navigateQuery,
|
|
249
|
+
navigatePath,
|
|
250
|
+
|
|
251
|
+
start() {
|
|
252
|
+
window.addEventListener('click', onClick);
|
|
253
|
+
window.addEventListener('popstate', onPop);
|
|
254
|
+
navigate(location.pathname, { replace: true, search: location.search, restoreScroll: true });
|
|
255
|
+
return this;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
stop() {
|
|
259
|
+
window.removeEventListener('click', onClick);
|
|
260
|
+
window.removeEventListener('popstate', onPop);
|
|
261
|
+
if (typeof current.unboot === 'function') {
|
|
262
|
+
try { current.unboot(); } catch {}
|
|
263
|
+
}
|
|
264
|
+
return this;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
getCurrent() {
|
|
268
|
+
return { ...current };
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// boundary.js — Minimal boundary helper for UIstate
|
|
2
|
+
// Design goals:
|
|
3
|
+
// - Never throw; always resolve to a value (or undefined) so callers can remain declarative
|
|
4
|
+
// - Toggle loading flags via provided setLoading(boolean)
|
|
5
|
+
// - Report errors via onError(error)
|
|
6
|
+
// - Optional mapError(error) to convert errors into return values
|
|
7
|
+
// - Optional finally() callback invoked regardless of outcome
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @template T
|
|
11
|
+
* @param {() => Promise<T>} fn async function to execute within the boundary
|
|
12
|
+
* @param {{
|
|
13
|
+
* setLoading?: (v:boolean)=>void,
|
|
14
|
+
* onError?: (error:any)=>void,
|
|
15
|
+
* mapError?: (error:any)=>any,
|
|
16
|
+
* finally?: ()=>void,
|
|
17
|
+
* }} opts
|
|
18
|
+
* @returns {Promise<T|any|undefined>}
|
|
19
|
+
*/
|
|
20
|
+
export async function runWithBoundary(fn, opts = {}){
|
|
21
|
+
const { setLoading, onError, mapError, finally: onFinally } = opts;
|
|
22
|
+
try {
|
|
23
|
+
if (setLoading) try { setLoading(true); } catch {}
|
|
24
|
+
const out = await fn();
|
|
25
|
+
return out;
|
|
26
|
+
} catch (err) {
|
|
27
|
+
try { onError && onError(err); } catch {}
|
|
28
|
+
if (typeof mapError === 'function') {
|
|
29
|
+
try { return mapError(err); } catch { return undefined; }
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
} finally {
|
|
33
|
+
if (setLoading) try { setLoading(false); } catch {}
|
|
34
|
+
if (onFinally) try { onFinally(); } catch {}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// converge.js — last-write-wins (or last-local-wins) convergence helpers
|
|
2
|
+
// Expects a store with get/set APIs.
|
|
3
|
+
|
|
4
|
+
function pathKey(path) {
|
|
5
|
+
return String(path || '');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Apply an inbound change if its clock is newer than the local clock.
|
|
10
|
+
* @param {any} store
|
|
11
|
+
* @param {{ path: string, value: any, clock: number }} change
|
|
12
|
+
* @param {{ clocksPath?: string, arrayStrategy?: 'replace'|'keyedMerge', keyField?: string }} opts
|
|
13
|
+
*/
|
|
14
|
+
export function applyConverge(store, change, opts = {}) {
|
|
15
|
+
const { path, value, clock } = change || {};
|
|
16
|
+
if (!path) return false;
|
|
17
|
+
const { clocksPath = 'clocks', arrayStrategy = 'replace', keyField = 'id' } = opts;
|
|
18
|
+
|
|
19
|
+
const ck = pathKey(path);
|
|
20
|
+
const localClock = store.get(`${clocksPath}.${ck}`) || 0;
|
|
21
|
+
if (!(clock > localClock)) return false; // reject stale or equal
|
|
22
|
+
|
|
23
|
+
// Apply the value with optional array strategy
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (arrayStrategy === 'replace') {
|
|
26
|
+
store.set(path, value);
|
|
27
|
+
} else {
|
|
28
|
+
const local = store.get(path) || [];
|
|
29
|
+
const result = [...local];
|
|
30
|
+
for (const inc of value) {
|
|
31
|
+
const k = inc && typeof inc === 'object' ? inc[keyField] : undefined;
|
|
32
|
+
if (k == null) { result.push(inc); continue; }
|
|
33
|
+
const sk = String(k);
|
|
34
|
+
const idx = result.findIndex((x) => x && x[keyField] != null && String(x[keyField]) === sk);
|
|
35
|
+
if (idx >= 0) {
|
|
36
|
+
const old = result[idx];
|
|
37
|
+
result[idx] = (old && typeof old === 'object' && inc && typeof inc === 'object')
|
|
38
|
+
? { ...old, ...inc }
|
|
39
|
+
: inc;
|
|
40
|
+
} else {
|
|
41
|
+
result.push(inc);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
store.set(path, result);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
store.set(path, value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Update clock
|
|
51
|
+
store.set(`${clocksPath}.${ck}`, clock);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build an outbound change with clock and origin-id (for crossTabSync).
|
|
57
|
+
*/
|
|
58
|
+
export function publishChange(store, path, value, { clocksPath = 'clocks', now = () => Date.now(), origin } = {}) {
|
|
59
|
+
const clock = Number(now());
|
|
60
|
+
store.set(path, value);
|
|
61
|
+
store.set(`${clocksPath}.${pathKey(path)}`, clock);
|
|
62
|
+
return { path, value, clock, origin };
|
|
63
|
+
}
|
|
@@ -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/eventState.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;
|