@vesk/runtime 0.2.59 → 0.2.60
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/dist/path-store.d.ts +123 -0
- package/dist/path-store.d.ts.map +1 -0
- package/dist/path-store.js +966 -0
- package/dist/ripple-blocks.d.ts.map +1 -1
- package/dist/ripple-blocks.js +12 -1
- package/dist/ripple-runtime.d.ts +36 -0
- package/dist/ripple-runtime.d.ts.map +1 -1
- package/dist/ripple-runtime.js +120 -3
- package/dist/store-persistence.d.ts +90 -0
- package/dist/store-persistence.d.ts.map +1 -0
- package/dist/store-persistence.js +154 -0
- package/dist/store.d.ts +61 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +275 -0
- package/package.json +23 -2
|
@@ -0,0 +1,966 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Store — a fine-grained reactive store whose every value knows *why* it
|
|
3
|
+
* holds what it holds.
|
|
4
|
+
*
|
|
5
|
+
* Three things no mainstream store offers together:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Path-level fine-grained reactivity.** `store.at('user.name')` is its own
|
|
8
|
+
* dependency. Reading it inside a binding re-runs only when `user.name`
|
|
9
|
+
* (or an ancestor that replaces it) changes — not on every store write.
|
|
10
|
+
* Whole-state writes are reconciled onto the existing path cells.
|
|
11
|
+
*
|
|
12
|
+
* 2. **A queryable causal graph.** Every commit records the action that produced
|
|
13
|
+
* it and the leaf paths it changed. `derived()` nodes are evaluated against a
|
|
14
|
+
* read-recording proxy, so their dependencies are captured automatically.
|
|
15
|
+
* The proxy also records an *envelope* dependency on the owning array when a
|
|
16
|
+
* selector scans an array (element reads or `.length`), so aggregate
|
|
17
|
+
* derivatives over arrays re-run on append/remove — not only on the element
|
|
18
|
+
* leaves that happened to exist on the last evaluation. `store.why(path)`
|
|
19
|
+
* walks the graph and answers "because action X wrote `items.3`".
|
|
20
|
+
*
|
|
21
|
+
* 3. **Structural-sharing time travel.** Immutable writes mean each version's
|
|
22
|
+
* snapshot shares untouched subtrees with its neighbours, so a full history
|
|
23
|
+
* costs only the changed spine. `undo` / `redo` restore a version.
|
|
24
|
+
*
|
|
25
|
+
* Reactivity contract (does NOT conflict with the ripple engine):
|
|
26
|
+
* - Path cells are real ripple `track()` cells, so `at(path).get()` and
|
|
27
|
+
* `derived` reads are first-class reactive reads in `.vsk` bindings.
|
|
28
|
+
* - ALL internal bookkeeping — commit, cell reconciliation, derived
|
|
29
|
+
* evaluation, time-travel restore — runs inside `untrack(...)`. Writing
|
|
30
|
+
* inside an effect therefore never subscribes that effect to unrelated
|
|
31
|
+
* paths, and derived selectors never inherit the caller's reactive scope.
|
|
32
|
+
* - Deriving a value reads plain state through a recording proxy; it does not
|
|
33
|
+
* subscribe to anything itself.
|
|
34
|
+
*
|
|
35
|
+
* Tightness:
|
|
36
|
+
* - A path-local write (`at(path).set`, `setIn`, `updateIn`) diffs ONLY the
|
|
37
|
+
* subtree under that path (the touched spine), not the whole tree, then
|
|
38
|
+
* reconciles only the cells on the touched spine and its descendants.
|
|
39
|
+
* - Derived invalidation goes through a dependency trie: a commit visits the
|
|
40
|
+
* deriveds whose deps are prefix/descendant-related to its changed paths —
|
|
41
|
+
* no O(derived × changes) scan.
|
|
42
|
+
* - Exotic values (Date, Map, Set, RegExp, typed arrays) survive state
|
|
43
|
+
* cloning; the internal state is kept immutable.
|
|
44
|
+
*
|
|
45
|
+
* SSR + persistence:
|
|
46
|
+
* - Same shared mechanism as `@vesk/runtime/store` (see `store-persistence.ts`):
|
|
47
|
+
* on the server a persisted store reads its value through the
|
|
48
|
+
* adapter-installed read hook (typically a request cookie), so SSR HTML
|
|
49
|
+
* renders the user's actual state (no theme flash, SEO-correct).
|
|
50
|
+
* - Constructed persisted keys are registered so the adapter can emit a tiny
|
|
51
|
+
* blocking `<head>` script that moves each persisted localStorage value into
|
|
52
|
+
* `window.__VESK_STORE_INIT__` before first paint (served at
|
|
53
|
+
* `/store-init.js?t=<token>`, never inlined — CSP-safe). The store init
|
|
54
|
+
* prefers that value, so hydration never flashes the default state. Client
|
|
55
|
+
* writes mirror to a same-site cookie for the next render.
|
|
56
|
+
*
|
|
57
|
+
* SSR-safe, no DOM at module scope, and never imports the deprecated `batch`.
|
|
58
|
+
*/
|
|
59
|
+
import { track, untrack } from './ripple-runtime.js';
|
|
60
|
+
import { getIn, setIn, updateIn } from './nested-update.js';
|
|
61
|
+
import { installPersistedStoreServerHooks as _installPersistedStoreServerHooks, persistedStoreCookieName, getPersistedStoreKeys as _getPersistedStoreKeys, resetPersistedStoreKeys as _resetPersistedStoreKeys, buildStorePrePaintPayload as _buildStorePrePaintPayload, readPersistedStorage, writePersistedStorage, } from './store-persistence.js';
|
|
62
|
+
/* ── SSR persistence hooks (shared with createStore — see store-persistence.ts) ── */
|
|
63
|
+
/** Install server-side persistence hooks (adapter calls this; shared with
|
|
64
|
+
* `@vesk/runtime/store`). */
|
|
65
|
+
export const installPathStoreServerHooks = _installPersistedStoreServerHooks;
|
|
66
|
+
/** The cookie name a persisted store is mirrored to, for SSR continuity. */
|
|
67
|
+
export { persistedStoreCookieName };
|
|
68
|
+
/** The storage keys of persisted stores constructed in this server render. */
|
|
69
|
+
export const getPersistedStoreKeys = _getPersistedStoreKeys;
|
|
70
|
+
/** Clear the constructed-key registry (called per SSR request by the adapter). */
|
|
71
|
+
export const resetPersistedStoreKeys = _resetPersistedStoreKeys;
|
|
72
|
+
/** Plain-JS body the adapter serves at `/store-init.js?t=<token>` (CSP-safe,
|
|
73
|
+
* never inlined) to populate `window.__VESK_STORE_INIT__` before first paint. */
|
|
74
|
+
export { _buildStorePrePaintPayload as buildStorePrePaintPayload };
|
|
75
|
+
/* ── path + value helpers (character scanners — no regex) ──────────────── */
|
|
76
|
+
function isObject(value) {
|
|
77
|
+
return value !== null && typeof value === 'object';
|
|
78
|
+
}
|
|
79
|
+
/** True when `s` is non-empty and made of decimal digits. */
|
|
80
|
+
function isDigits(s) {
|
|
81
|
+
if (s.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
for (let i = 0; i < s.length; i++) {
|
|
84
|
+
const c = s.charCodeAt(i);
|
|
85
|
+
if (c < 48 || c > 57)
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse a human path string into segments. `'items[0].done'` → `['items', 0,
|
|
92
|
+
* 'done']`. A character loop, consistent with the runtime tokenizer style.
|
|
93
|
+
*/
|
|
94
|
+
export function parseStorePath(input) {
|
|
95
|
+
const tokens = [];
|
|
96
|
+
let i = 0;
|
|
97
|
+
const n = input.length;
|
|
98
|
+
const readBracketed = () => {
|
|
99
|
+
i++; // consume '['
|
|
100
|
+
let start = i;
|
|
101
|
+
while (i < n && input[i] >= '0' && input[i] <= '9')
|
|
102
|
+
i++;
|
|
103
|
+
if (start === i || input[i] !== ']') {
|
|
104
|
+
throw new Error(`Path store: bad index in path "${input}"`);
|
|
105
|
+
}
|
|
106
|
+
tokens.push(Number(input.slice(start, i)));
|
|
107
|
+
i++; // consume ']'
|
|
108
|
+
};
|
|
109
|
+
while (i < n) {
|
|
110
|
+
const c = input[i];
|
|
111
|
+
if (c === '.') {
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (c === '[') {
|
|
116
|
+
readBracketed();
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
let start = i;
|
|
120
|
+
while (i < n && input[i] !== '.' && input[i] !== '[')
|
|
121
|
+
i++;
|
|
122
|
+
tokens.push(input.slice(start, i));
|
|
123
|
+
}
|
|
124
|
+
return tokens;
|
|
125
|
+
}
|
|
126
|
+
function pathKey(tokens) {
|
|
127
|
+
return tokens.join('\u0000');
|
|
128
|
+
}
|
|
129
|
+
function tokensOfKey(key) {
|
|
130
|
+
if (key === '')
|
|
131
|
+
return [];
|
|
132
|
+
return key.split('\u0000').map((t) => (isDigits(t) ? Number(t) : t));
|
|
133
|
+
}
|
|
134
|
+
function isPrefix(prefix, full) {
|
|
135
|
+
if (prefix.length > full.length)
|
|
136
|
+
return false;
|
|
137
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
138
|
+
if (String(prefix[i]) !== String(full[i]))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
function isStrictPrefix(prefix, full) {
|
|
144
|
+
return prefix.length > 0 && prefix.length < full.length && isPrefix(prefix, full);
|
|
145
|
+
}
|
|
146
|
+
/** Clone preserving prototypes: Date, RegExp, Map, Set, typed arrays, and
|
|
147
|
+
* plain objects/arrays. Other class instances are structured-cloned when
|
|
148
|
+
* possible and otherwise shared by reference (documented: exotic objects
|
|
149
|
+
* defined outside the store may be shared, never mutated by the store). */
|
|
150
|
+
function cloneValue(value, seen) {
|
|
151
|
+
if (value === null || typeof value !== 'object')
|
|
152
|
+
return value;
|
|
153
|
+
seen = seen ?? new Map();
|
|
154
|
+
const existing = seen.get(value);
|
|
155
|
+
if (existing !== undefined)
|
|
156
|
+
return existing;
|
|
157
|
+
if (value instanceof Date) {
|
|
158
|
+
const d = new Date(value.getTime());
|
|
159
|
+
seen.set(value, d);
|
|
160
|
+
return d;
|
|
161
|
+
}
|
|
162
|
+
if (value instanceof RegExp) {
|
|
163
|
+
const r = new RegExp(value.source, value.flags);
|
|
164
|
+
seen.set(value, r);
|
|
165
|
+
return r;
|
|
166
|
+
}
|
|
167
|
+
if (value instanceof Map) {
|
|
168
|
+
const out = new Map();
|
|
169
|
+
seen.set(value, out);
|
|
170
|
+
for (const [k, v] of value)
|
|
171
|
+
out.set(cloneValue(k, seen), cloneValue(v, seen));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
if (value instanceof Set) {
|
|
175
|
+
const out = new Set();
|
|
176
|
+
seen.set(value, out);
|
|
177
|
+
for (const v of value)
|
|
178
|
+
out.add(cloneValue(v, seen));
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(value)) {
|
|
182
|
+
const out = [];
|
|
183
|
+
seen.set(value, out);
|
|
184
|
+
for (const item of value)
|
|
185
|
+
out.push(cloneValue(item, seen));
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
if (ArrayBuffer.isView(value)) {
|
|
189
|
+
const out = value.slice();
|
|
190
|
+
seen.set(value, out);
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
const proto = Object.getPrototypeOf(value);
|
|
194
|
+
if (proto === Object.prototype || proto === null) {
|
|
195
|
+
const out = {};
|
|
196
|
+
seen.set(value, out);
|
|
197
|
+
for (const key of Object.keys(value)) {
|
|
198
|
+
out[key] = cloneValue(value[key], seen);
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
// Class instance or exotic host object: keep it intact (never mutated by
|
|
203
|
+
// the store). structuredClone first, share by reference when it fails.
|
|
204
|
+
seen.set(value, value);
|
|
205
|
+
try {
|
|
206
|
+
return structuredClone(value);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** JSON-safe snapshot conversion for `toJSON`: Date→ISO string, Map/Set→
|
|
213
|
+
* arrays, other objects stay plain. Throws only on genuinely unserializable
|
|
214
|
+
* input (functions, cycles); callers guard with a JSON round-trip. */
|
|
215
|
+
function plainClone(value, seen) {
|
|
216
|
+
if (value === null || typeof value !== 'object')
|
|
217
|
+
return value;
|
|
218
|
+
seen = seen ?? new Set();
|
|
219
|
+
if (seen.has(value))
|
|
220
|
+
throw new Error('Path store: toJSON() cannot serialize cyclic state');
|
|
221
|
+
seen.add(value);
|
|
222
|
+
if (value instanceof Date)
|
|
223
|
+
return value.toISOString();
|
|
224
|
+
if (value instanceof RegExp)
|
|
225
|
+
return String(value);
|
|
226
|
+
if (value instanceof Map) {
|
|
227
|
+
const out = [];
|
|
228
|
+
for (const [k, v] of value)
|
|
229
|
+
out.push([plainClone(k, seen), plainClone(v, seen)]);
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
if (value instanceof Set) {
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const v of value)
|
|
235
|
+
out.push(plainClone(v, seen));
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
if (Array.isArray(value)) {
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const item of value)
|
|
241
|
+
out.push(plainClone(item, seen));
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
const out = {};
|
|
245
|
+
for (const key of Object.keys(value)) {
|
|
246
|
+
out[key] = plainClone(value[key], seen);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/** Leaf diff of two subtrees, rooted at `base`. Only paths whose *value*
|
|
251
|
+
* identity differs are reported. */
|
|
252
|
+
function shallowDiff(before, after, base, out) {
|
|
253
|
+
if (Object.is(before, after))
|
|
254
|
+
return;
|
|
255
|
+
const beforeObj = isObject(before);
|
|
256
|
+
const afterObj = isObject(after);
|
|
257
|
+
if (!beforeObj || !afterObj || Array.isArray(before) !== Array.isArray(after)) {
|
|
258
|
+
out.push({ path: pathKey(base), before, after });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const array = Array.isArray(before);
|
|
262
|
+
const keys = new Set([
|
|
263
|
+
...Object.keys(before),
|
|
264
|
+
...Object.keys(after),
|
|
265
|
+
]);
|
|
266
|
+
for (const key of keys) {
|
|
267
|
+
const segment = array && isDigits(key) ? Number(key) : key;
|
|
268
|
+
shallowDiff(before[key], after[key], [...base, segment], out);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/* ── implementation ────────────────────────────────────────────────────── */
|
|
272
|
+
export function createPathStore(initial, options = {}) {
|
|
273
|
+
const { persist = false, storageKey, historyLimit = 500 } = options;
|
|
274
|
+
/* Initial state: prefer persisted value on the client (pre-paint or
|
|
275
|
+
* localStorage), SSR hook on the server, else the shipped seed. A shallow
|
|
276
|
+
* merge keeps fields added by newer app versions. */
|
|
277
|
+
let state = cloneValue(initial);
|
|
278
|
+
if (persist && storageKey) {
|
|
279
|
+
const raw = readPersistedStorage(storageKey);
|
|
280
|
+
if (raw) {
|
|
281
|
+
try {
|
|
282
|
+
const parsed = JSON.parse(raw);
|
|
283
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
284
|
+
state = { ...state, ...cloneValue(parsed) };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
/* corrupt payload — fall back to the shipped seed */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const rootCell = track(state);
|
|
293
|
+
const cellRoot = {};
|
|
294
|
+
function nodeFor(path, create) {
|
|
295
|
+
let node = cellRoot;
|
|
296
|
+
for (const seg of path) {
|
|
297
|
+
const key = String(seg);
|
|
298
|
+
let child = node.children?.get(key);
|
|
299
|
+
if (!child) {
|
|
300
|
+
if (!create)
|
|
301
|
+
return null;
|
|
302
|
+
child = {};
|
|
303
|
+
if (!node.children)
|
|
304
|
+
node.children = new Map();
|
|
305
|
+
node.children.set(key, child);
|
|
306
|
+
}
|
|
307
|
+
node = child;
|
|
308
|
+
}
|
|
309
|
+
return node;
|
|
310
|
+
}
|
|
311
|
+
function cellFor(path) {
|
|
312
|
+
const node = nodeFor(path, true);
|
|
313
|
+
if (!node.cell) {
|
|
314
|
+
node.cell = track(getIn(state, path));
|
|
315
|
+
setImprint(node, path);
|
|
316
|
+
}
|
|
317
|
+
return node.cell;
|
|
318
|
+
}
|
|
319
|
+
function collectCellNodes(node) {
|
|
320
|
+
const out = [];
|
|
321
|
+
if (!node)
|
|
322
|
+
return out;
|
|
323
|
+
const stack = [node];
|
|
324
|
+
while (stack.length > 0) {
|
|
325
|
+
const n = stack.pop();
|
|
326
|
+
if (n.cell)
|
|
327
|
+
out.push(n);
|
|
328
|
+
if (n.children)
|
|
329
|
+
for (const child of n.children.values())
|
|
330
|
+
stack.push(child);
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/** Nodes whose path is an ancestor of (or equal to, or under) `path`. */
|
|
335
|
+
function cellsRelatedTo(path) {
|
|
336
|
+
const out = [];
|
|
337
|
+
const tNode = nodeFor(path, false);
|
|
338
|
+
out.push(...collectCellNodes(tNode));
|
|
339
|
+
for (let i = path.length - 1; i > 0; i--) {
|
|
340
|
+
const ancestor = nodeFor(path.slice(0, i), false);
|
|
341
|
+
if (ancestor?.cell)
|
|
342
|
+
out.push(ancestor);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
function syncCell(node, path) {
|
|
347
|
+
const cell = node.cell;
|
|
348
|
+
const next = getIn(state, path);
|
|
349
|
+
if (!Object.is(cell.get(), next))
|
|
350
|
+
cell.set(next);
|
|
351
|
+
}
|
|
352
|
+
/** Reconcile cells after a commit. `touched` is the write's own path when
|
|
353
|
+
* known — only that spine and its subtree are visited. */
|
|
354
|
+
function syncPathCells(touched) {
|
|
355
|
+
if (touched === null) {
|
|
356
|
+
// Whole-state write: every created cell re-reads from the new state.
|
|
357
|
+
for (const node of collectCellNodes(cellRoot)) {
|
|
358
|
+
if (node.cell)
|
|
359
|
+
syncCell(node, nodePathOf(node));
|
|
360
|
+
}
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
for (const node of cellsRelatedTo(touched)) {
|
|
364
|
+
if (!node.cell)
|
|
365
|
+
continue;
|
|
366
|
+
syncCell(node, nodePathOf(node));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Recover a node's path by walking its position in the trie parent map. */
|
|
370
|
+
function nodePathOf(node) {
|
|
371
|
+
return nodePathCache.get(node) ?? [];
|
|
372
|
+
}
|
|
373
|
+
/* Maintain a cheap parent→path imprint so `nodePathOf` is O(depth) without
|
|
374
|
+
* a second data structure detail leaking into recompute. Nodes are keyed by
|
|
375
|
+
* identity; `setImprint` runs when a cell is created. */
|
|
376
|
+
const nodePathCache = new Map();
|
|
377
|
+
function setImprint(node, path) {
|
|
378
|
+
nodePathCache.set(node, path.slice());
|
|
379
|
+
}
|
|
380
|
+
const derivedDefs = new Map();
|
|
381
|
+
const depRoot = { defs: null };
|
|
382
|
+
function depNodeFor(tokens, create) {
|
|
383
|
+
let node = depRoot;
|
|
384
|
+
for (const seg of tokens) {
|
|
385
|
+
const key = String(seg);
|
|
386
|
+
let child = node.children?.get(key);
|
|
387
|
+
if (!child) {
|
|
388
|
+
if (!create)
|
|
389
|
+
return null;
|
|
390
|
+
child = { defs: null };
|
|
391
|
+
if (!node.children)
|
|
392
|
+
node.children = new Map();
|
|
393
|
+
node.children.set(key, child);
|
|
394
|
+
}
|
|
395
|
+
node = child;
|
|
396
|
+
}
|
|
397
|
+
return node;
|
|
398
|
+
}
|
|
399
|
+
function registerDep(def, tokens) {
|
|
400
|
+
const node = depNodeFor(tokens, true);
|
|
401
|
+
if (!node.defs)
|
|
402
|
+
node.defs = new Set();
|
|
403
|
+
node.defs.add(def);
|
|
404
|
+
}
|
|
405
|
+
function unregisterDep(def, tokens) {
|
|
406
|
+
const node = depNodeFor(tokens, false);
|
|
407
|
+
if (node?.defs) {
|
|
408
|
+
node.defs.delete(def);
|
|
409
|
+
if (node.defs.size === 0)
|
|
410
|
+
node.defs = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function registerDef(def) {
|
|
414
|
+
for (const dep of def.deps)
|
|
415
|
+
registerDep(def, dep);
|
|
416
|
+
}
|
|
417
|
+
function unregisterDef(def) {
|
|
418
|
+
for (const dep of def.deps)
|
|
419
|
+
unregisterDep(def, dep);
|
|
420
|
+
}
|
|
421
|
+
/** All deriveds whose deps are prefix/descendant-related to `path` — the
|
|
422
|
+
* exact same relationship as `relate()` on the old flat scan. Fills `out`
|
|
423
|
+
* (used for dedup across waves) and returns the collected defs. */
|
|
424
|
+
function derivedsRelatedTo(path, out) {
|
|
425
|
+
const n = depNodeFor(path, false);
|
|
426
|
+
if (n) {
|
|
427
|
+
const stack = [n];
|
|
428
|
+
while (stack.length > 0) {
|
|
429
|
+
const node = stack.pop();
|
|
430
|
+
if (node.defs)
|
|
431
|
+
for (const def of node.defs)
|
|
432
|
+
out.add(def);
|
|
433
|
+
if (node.children)
|
|
434
|
+
for (const child of node.children.values())
|
|
435
|
+
stack.push(child);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (let i = path.length - 1; i > 0; i--) {
|
|
439
|
+
const ancestor = depNodeFor(path.slice(0, i), false);
|
|
440
|
+
if (ancestor?.defs)
|
|
441
|
+
for (const def of ancestor.defs)
|
|
442
|
+
out.add(def);
|
|
443
|
+
}
|
|
444
|
+
return Array.from(out);
|
|
445
|
+
}
|
|
446
|
+
/* ── derived evaluation with read-recording + array envelopes ── */
|
|
447
|
+
const COLLAPSE_THRESHOLD = 8;
|
|
448
|
+
/**
|
|
449
|
+
* Evaluate a derived selector ONCE: inside `untrack` (it must not inherit
|
|
450
|
+
* the caller's reactive scope), through a read-recording proxy. Array
|
|
451
|
+
* element reads and `.length` reads also record the owning array path as an
|
|
452
|
+
* "envelope" dependency, so aggregate derivatives re-run when the array's
|
|
453
|
+
* membership changes — the leaf-only recording could not see an append.
|
|
454
|
+
* When more than COLLAPSE_THRESHOLD leaves fall under one envelope, they
|
|
455
|
+
* collapse to the envelope alone (selectors that scan a big array depend on
|
|
456
|
+
* the whole array anyway).
|
|
457
|
+
*/
|
|
458
|
+
const proxyTargets = new WeakMap();
|
|
459
|
+
function evaluate(selector) {
|
|
460
|
+
return untrack(() => {
|
|
461
|
+
const deps = [];
|
|
462
|
+
const seen = new Set();
|
|
463
|
+
const envelopeLeaves = new Map();
|
|
464
|
+
const collapsed = new Set();
|
|
465
|
+
const recordLeaf = (path) => {
|
|
466
|
+
const k = pathKey(path);
|
|
467
|
+
if (!seen.has(k)) {
|
|
468
|
+
seen.add(k);
|
|
469
|
+
deps.push(path);
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const recordEnvelope = (env) => {
|
|
473
|
+
if (env.length === 0)
|
|
474
|
+
return;
|
|
475
|
+
const k = pathKey(env);
|
|
476
|
+
const count = (envelopeLeaves.get(k) ?? 0) + 1;
|
|
477
|
+
envelopeLeaves.set(k, count);
|
|
478
|
+
if (count >= COLLAPSE_THRESHOLD)
|
|
479
|
+
collapsed.add(k);
|
|
480
|
+
recordLeaf(env);
|
|
481
|
+
};
|
|
482
|
+
const isPlain = (value) => value !== null &&
|
|
483
|
+
typeof value === 'object' &&
|
|
484
|
+
(Array.isArray(value) ||
|
|
485
|
+
Object.prototype.toString.call(value) === '[object Object]');
|
|
486
|
+
const wrap = (value, base) => {
|
|
487
|
+
if (!isPlain(value))
|
|
488
|
+
return value;
|
|
489
|
+
const proxy = new Proxy(value, {
|
|
490
|
+
get(target, prop, receiver) {
|
|
491
|
+
if (typeof prop === 'symbol')
|
|
492
|
+
return Reflect.get(target, prop, receiver);
|
|
493
|
+
const array = Array.isArray(target);
|
|
494
|
+
const segment = array && isDigits(String(prop)) ? Number(prop) : String(prop);
|
|
495
|
+
const child = base.concat(segment);
|
|
496
|
+
const v = Reflect.get(target, prop, receiver);
|
|
497
|
+
if (isPlain(v))
|
|
498
|
+
return wrap(v, child);
|
|
499
|
+
if (typeof v === 'function')
|
|
500
|
+
return v;
|
|
501
|
+
recordLeaf(child);
|
|
502
|
+
// Element or `.length` reads cross the array membership: an
|
|
503
|
+
// aggregate over the array depends on the array as a whole.
|
|
504
|
+
if (array)
|
|
505
|
+
recordEnvelope(base);
|
|
506
|
+
return v;
|
|
507
|
+
},
|
|
508
|
+
set() {
|
|
509
|
+
throw new Error('Path store: derived selectors must be pure — no writes.');
|
|
510
|
+
},
|
|
511
|
+
});
|
|
512
|
+
proxyTargets.set(proxy, value);
|
|
513
|
+
return proxy;
|
|
514
|
+
};
|
|
515
|
+
// Replace any recording proxy still embedded in the result with its
|
|
516
|
+
// real target, rebuilding containers only when they held proxies.
|
|
517
|
+
const unwrap = (value) => {
|
|
518
|
+
if (value === null || typeof value !== 'object')
|
|
519
|
+
return value;
|
|
520
|
+
const target = proxyTargets.get(value);
|
|
521
|
+
if (target !== undefined)
|
|
522
|
+
return unwrap(target);
|
|
523
|
+
if (Array.isArray(value)) {
|
|
524
|
+
let changed = false;
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const item of value) {
|
|
527
|
+
const next = unwrap(item);
|
|
528
|
+
if (next !== item)
|
|
529
|
+
changed = true;
|
|
530
|
+
out.push(next);
|
|
531
|
+
}
|
|
532
|
+
return changed ? out : value;
|
|
533
|
+
}
|
|
534
|
+
let changed = false;
|
|
535
|
+
const out = {};
|
|
536
|
+
for (const key of Object.keys(value)) {
|
|
537
|
+
const next = unwrap(value[key]);
|
|
538
|
+
if (next !== value[key])
|
|
539
|
+
changed = true;
|
|
540
|
+
out[key] = next;
|
|
541
|
+
}
|
|
542
|
+
return changed ? out : value;
|
|
543
|
+
};
|
|
544
|
+
const result = selector(wrap(state, []));
|
|
545
|
+
const finalDeps = collapsed.size === 0
|
|
546
|
+
? deps
|
|
547
|
+
: deps.filter((d) => {
|
|
548
|
+
for (const envKey of collapsed) {
|
|
549
|
+
if (isStrictPrefix(tokensOfKey(envKey), d))
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
});
|
|
554
|
+
return { value: unwrap(result), deps: finalDeps };
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
function recomputeDerived(changes, version) {
|
|
558
|
+
const queue = [];
|
|
559
|
+
const queued = new Set();
|
|
560
|
+
// `derivedsRelatedTo` fills its own dedup set (so one path's related
|
|
561
|
+
// defs are unique); enqueue through a separate set so a def collected
|
|
562
|
+
// here is still eligible to be processed exactly once below.
|
|
563
|
+
const collect = (tokens) => {
|
|
564
|
+
for (const def of derivedsRelatedTo(tokens, new Set())) {
|
|
565
|
+
if (!queued.has(def)) {
|
|
566
|
+
queued.add(def);
|
|
567
|
+
queue.push(def);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
for (const change of changes)
|
|
572
|
+
collect(tokensOfKey(change.path));
|
|
573
|
+
while (queue.length > 0) {
|
|
574
|
+
const def = queue.pop();
|
|
575
|
+
unregisterDef(def);
|
|
576
|
+
const { value, deps } = evaluate(def.selector);
|
|
577
|
+
def.deps = deps;
|
|
578
|
+
registerDef(def);
|
|
579
|
+
const before = def.cell.get();
|
|
580
|
+
if (!def.equals(before, value)) {
|
|
581
|
+
def.cell.set(value);
|
|
582
|
+
def.lastTrigger = changes.map((c) => tokensOfKey(c.path));
|
|
583
|
+
def.lastTriggerVersion = version;
|
|
584
|
+
mergedDirty = true;
|
|
585
|
+
const change = { path: def.path, before, after: value };
|
|
586
|
+
changes.push(change);
|
|
587
|
+
collect(def.tokens);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/* ── merged snapshot (derived values visible in get()/toJSON) ── */
|
|
592
|
+
let mergedCache = null;
|
|
593
|
+
let mergedDirty = true;
|
|
594
|
+
function invalidateMerged() {
|
|
595
|
+
mergedCache = null;
|
|
596
|
+
mergedDirty = true;
|
|
597
|
+
}
|
|
598
|
+
function buildMerged() {
|
|
599
|
+
if (!mergedDirty && mergedCache !== null)
|
|
600
|
+
return mergedCache;
|
|
601
|
+
const base = rootCell.get();
|
|
602
|
+
if (derivedDefs.size === 0) {
|
|
603
|
+
mergedCache = base;
|
|
604
|
+
mergedDirty = false;
|
|
605
|
+
return base;
|
|
606
|
+
}
|
|
607
|
+
let acc = base;
|
|
608
|
+
for (const def of derivedDefs.values()) {
|
|
609
|
+
acc = setIn(acc, def.tokens, def.cell.get());
|
|
610
|
+
}
|
|
611
|
+
mergedCache = acc;
|
|
612
|
+
mergedDirty = false;
|
|
613
|
+
return acc;
|
|
614
|
+
}
|
|
615
|
+
/* ── history / causality ── */
|
|
616
|
+
const events = [];
|
|
617
|
+
let version = 0;
|
|
618
|
+
let undoStack = [];
|
|
619
|
+
let redoStack = [];
|
|
620
|
+
/** Per-path last cause — survives the events cap, so `why` never
|
|
621
|
+
* mislabels an old-but-written value as `initial`. */
|
|
622
|
+
const lastCause = new Map();
|
|
623
|
+
function recordCause(changes, action, atVersion) {
|
|
624
|
+
for (const change of changes) {
|
|
625
|
+
lastCause.set(change.path, { action, version: atVersion });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/* ── commit ── */
|
|
629
|
+
function commit(next, action, touched) {
|
|
630
|
+
untrack(() => {
|
|
631
|
+
if (Object.is(next, state))
|
|
632
|
+
return;
|
|
633
|
+
const changes = [];
|
|
634
|
+
if (touched !== null) {
|
|
635
|
+
// Path-local write: diff only the touched subtree.
|
|
636
|
+
shallowDiff(getIn(state, touched), getIn(next, touched), touched, changes);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
shallowDiff(state, next, [], changes);
|
|
640
|
+
}
|
|
641
|
+
if (changes.length === 0)
|
|
642
|
+
return;
|
|
643
|
+
undoStack.push(state);
|
|
644
|
+
if (undoStack.length > historyLimit)
|
|
645
|
+
undoStack.shift();
|
|
646
|
+
redoStack = [];
|
|
647
|
+
state = next;
|
|
648
|
+
rootCell.set(state);
|
|
649
|
+
invalidateMerged();
|
|
650
|
+
const nextVersion = version + 1;
|
|
651
|
+
recordCause(changes, action, nextVersion);
|
|
652
|
+
recomputeDerived(changes, nextVersion);
|
|
653
|
+
syncPathCells(touched);
|
|
654
|
+
dispatchAll();
|
|
655
|
+
persistToStorage();
|
|
656
|
+
version = nextVersion;
|
|
657
|
+
events.push({ version, action, at: Date.now(), changes });
|
|
658
|
+
if (events.length > historyLimit)
|
|
659
|
+
events.shift();
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
function restore(snapshot, action) {
|
|
663
|
+
const changes = [];
|
|
664
|
+
untrack(() => {
|
|
665
|
+
shallowDiff(state, snapshot, [], changes);
|
|
666
|
+
state = snapshot;
|
|
667
|
+
rootCell.set(state);
|
|
668
|
+
invalidateMerged();
|
|
669
|
+
const nextVersion = version + 1;
|
|
670
|
+
recordCause(changes, action, nextVersion);
|
|
671
|
+
recomputeDerived(changes, nextVersion);
|
|
672
|
+
syncPathCells(null);
|
|
673
|
+
if (changes.length > 0)
|
|
674
|
+
dispatchAll();
|
|
675
|
+
persistToStorage();
|
|
676
|
+
version = nextVersion;
|
|
677
|
+
events.push({ version, action, at: Date.now(), changes });
|
|
678
|
+
if (events.length > historyLimit)
|
|
679
|
+
events.shift();
|
|
680
|
+
});
|
|
681
|
+
return changes;
|
|
682
|
+
}
|
|
683
|
+
/* ── persistence ── */
|
|
684
|
+
function persistToStorage() {
|
|
685
|
+
if (!persist || !storageKey)
|
|
686
|
+
return;
|
|
687
|
+
try {
|
|
688
|
+
writePersistedStorage(storageKey, JSON.stringify(state));
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
/* storage/cookie blocked — the in-memory store still works */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/* ── notifications ── */
|
|
695
|
+
const subscribers = new Set();
|
|
696
|
+
const watchers = new Set();
|
|
697
|
+
function dispatchAll() {
|
|
698
|
+
const current = buildMerged();
|
|
699
|
+
for (const wrapper of subscribers)
|
|
700
|
+
wrapper(current);
|
|
701
|
+
for (const wrapper of watchers)
|
|
702
|
+
wrapper();
|
|
703
|
+
}
|
|
704
|
+
/* ── why / explain ── */
|
|
705
|
+
function whyAt(tokens, depth = 0, versionNow = version) {
|
|
706
|
+
const path = pathKey(tokens);
|
|
707
|
+
const value = getIn(state, tokens);
|
|
708
|
+
if (depth > 64)
|
|
709
|
+
return { path, value, cause: 'initial' };
|
|
710
|
+
const def = derivedDefs.get(path);
|
|
711
|
+
if (def) {
|
|
712
|
+
const node = {
|
|
713
|
+
path,
|
|
714
|
+
value: def.cell.get(),
|
|
715
|
+
cause: 'derived',
|
|
716
|
+
from: def.deps.map((dep) => whyAt(dep, depth + 1)),
|
|
717
|
+
};
|
|
718
|
+
if (def.lastTrigger) {
|
|
719
|
+
node.changedBy = def.lastTrigger.map((trigger, i) => ({
|
|
720
|
+
path: pathKey(trigger),
|
|
721
|
+
value: getIn(state, trigger),
|
|
722
|
+
cause: 'action',
|
|
723
|
+
action: i === 0 ? undefined : undefined, // filled below from events
|
|
724
|
+
version: def.lastTriggerVersion,
|
|
725
|
+
}));
|
|
726
|
+
const first = def.lastTrigger[0];
|
|
727
|
+
const ev = events
|
|
728
|
+
.slice()
|
|
729
|
+
.reverse()
|
|
730
|
+
.find((e) => e.changes.some((c) => c.path === pathKey(first)));
|
|
731
|
+
if (ev) {
|
|
732
|
+
node.changedBy[0].action = ev.action;
|
|
733
|
+
node.changedBy[0].version = ev.version;
|
|
734
|
+
node.version = ev.version;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
void versionNow;
|
|
738
|
+
return node;
|
|
739
|
+
}
|
|
740
|
+
const cause = lastCause.get(path);
|
|
741
|
+
if (cause) {
|
|
742
|
+
if (cause.action === 'undo' || cause.action === 'redo') {
|
|
743
|
+
return { path, value, cause: 'time-travel', action: cause.action, version: cause.version };
|
|
744
|
+
}
|
|
745
|
+
return { path, value, cause: 'action', action: cause.action, version: cause.version };
|
|
746
|
+
}
|
|
747
|
+
return { path, value, cause: 'initial' };
|
|
748
|
+
}
|
|
749
|
+
function renderExplanation(node, indent = 0) {
|
|
750
|
+
const pad = ' '.repeat(indent);
|
|
751
|
+
const lines = [];
|
|
752
|
+
const tag = node.cause === 'derived'
|
|
753
|
+
? 'derived'
|
|
754
|
+
: node.cause === 'action'
|
|
755
|
+
? `${node.action ?? 'action'} \u00b7 v${node.version ?? '?'}`
|
|
756
|
+
: node.cause === 'time-travel'
|
|
757
|
+
? `time-travel \u00b7 v${node.version ?? '?'}`
|
|
758
|
+
: 'initial';
|
|
759
|
+
lines.push(`${pad}${node.path} = ${JSON.stringify(node.value)} (${tag})`);
|
|
760
|
+
if (node.changedBy && node.changedBy.length > 0) {
|
|
761
|
+
lines.push(`${pad} recomputed after:`);
|
|
762
|
+
for (const change of node.changedBy) {
|
|
763
|
+
lines.push(renderExplanation({ ...change, from: undefined }, indent + 2));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else if (node.from && node.from.length > 0) {
|
|
767
|
+
for (const child of node.from)
|
|
768
|
+
lines.push(renderExplanation(child, indent + 1));
|
|
769
|
+
}
|
|
770
|
+
return lines.join('\n');
|
|
771
|
+
}
|
|
772
|
+
/* ── handles ── */
|
|
773
|
+
function derivedHandle(def, displayPath) {
|
|
774
|
+
return {
|
|
775
|
+
path: def.path,
|
|
776
|
+
get deps() {
|
|
777
|
+
return def.deps.map((dep) => dep.join('.'));
|
|
778
|
+
},
|
|
779
|
+
get() {
|
|
780
|
+
return def.cell.get();
|
|
781
|
+
},
|
|
782
|
+
subscribe(cb) {
|
|
783
|
+
let prev = def.cell.get();
|
|
784
|
+
return store.subscribe(() => {
|
|
785
|
+
const next = def.cell.get();
|
|
786
|
+
if (!Object.is(prev, next)) {
|
|
787
|
+
prev = next;
|
|
788
|
+
cb(next);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
void displayPath;
|
|
794
|
+
}
|
|
795
|
+
const store = {
|
|
796
|
+
/** Reactive whole-state read — re-runs on any commit. Includes derived
|
|
797
|
+
* values at their registered paths (derived wins over a raw key). */
|
|
798
|
+
get() {
|
|
799
|
+
rootCell.get();
|
|
800
|
+
return buildMerged();
|
|
801
|
+
},
|
|
802
|
+
/** A fine-grained, causally-aware handle for one nested path. When a
|
|
803
|
+
* derived is registered at this path, the handle reads/writes the
|
|
804
|
+
* derived (set/update then throw read-only). */
|
|
805
|
+
at(path) {
|
|
806
|
+
const tokens = parseStorePath(path);
|
|
807
|
+
const key = pathKey(tokens);
|
|
808
|
+
const def = derivedDefs.get(key);
|
|
809
|
+
if (def) {
|
|
810
|
+
const handle = derivedHandle(def, path);
|
|
811
|
+
return {
|
|
812
|
+
path: key,
|
|
813
|
+
get: handle.get,
|
|
814
|
+
set() {
|
|
815
|
+
throw new Error(`Path store: derived path "${path}" is read-only.`);
|
|
816
|
+
},
|
|
817
|
+
update() {
|
|
818
|
+
throw new Error(`Path store: derived path "${path}" is read-only.`);
|
|
819
|
+
},
|
|
820
|
+
subscribe: handle.subscribe,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
path: key,
|
|
825
|
+
get() {
|
|
826
|
+
return cellFor(tokens).get();
|
|
827
|
+
},
|
|
828
|
+
set(value, action = 'set') {
|
|
829
|
+
commit(setIn(state, tokens, cloneValue(value)), action, tokens);
|
|
830
|
+
},
|
|
831
|
+
update(fn, action = 'update') {
|
|
832
|
+
commit(updateIn(state, tokens, fn), action, tokens);
|
|
833
|
+
},
|
|
834
|
+
subscribe(cb) {
|
|
835
|
+
let prev = cellFor(tokens).get();
|
|
836
|
+
return store.subscribe(() => {
|
|
837
|
+
const next = cellFor(tokens).get();
|
|
838
|
+
if (!Object.is(prev, next)) {
|
|
839
|
+
prev = next;
|
|
840
|
+
cb(next);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
},
|
|
846
|
+
/** Replace the whole state (attributed to `action`). */
|
|
847
|
+
set(next, action = 'set') {
|
|
848
|
+
commit(cloneValue(next), action, null);
|
|
849
|
+
},
|
|
850
|
+
/** Shallow merge a partial (or `(prev) => partial`) into the root. */
|
|
851
|
+
update(patch, action = 'update') {
|
|
852
|
+
const partial = typeof patch === 'function' ? patch(state) : patch;
|
|
853
|
+
commit({ ...state, ...cloneValue(partial) }, action, null);
|
|
854
|
+
},
|
|
855
|
+
setIn(path, value, action = 'setIn') {
|
|
856
|
+
const tokens = typeof path === 'string' ? parseStorePath(path) : path;
|
|
857
|
+
commit(setIn(state, tokens, cloneValue(value)), action, tokens);
|
|
858
|
+
},
|
|
859
|
+
updateIn(path, fn, action = 'updateIn') {
|
|
860
|
+
const tokens = typeof path === 'string' ? parseStorePath(path) : path;
|
|
861
|
+
commit(updateIn(state, tokens, fn), action, tokens);
|
|
862
|
+
},
|
|
863
|
+
/**
|
|
864
|
+
* Register a derived node at `path`. The selector is evaluated inside
|
|
865
|
+
* `untrack` against a read-recording proxy, so its source paths are
|
|
866
|
+
* captured automatically — including array envelopes, which make
|
|
867
|
+
* aggregate deriveds re-run when the array's membership changes.
|
|
868
|
+
*/
|
|
869
|
+
derived(path, selector, equals = Object.is) {
|
|
870
|
+
const tokens = parseStorePath(path);
|
|
871
|
+
const key = pathKey(tokens);
|
|
872
|
+
const first = evaluate(selector);
|
|
873
|
+
const def = {
|
|
874
|
+
path: key,
|
|
875
|
+
tokens,
|
|
876
|
+
selector: selector,
|
|
877
|
+
equals: equals,
|
|
878
|
+
cell: track(first.value),
|
|
879
|
+
deps: first.deps,
|
|
880
|
+
lastTrigger: null,
|
|
881
|
+
lastTriggerVersion: 0,
|
|
882
|
+
};
|
|
883
|
+
derivedDefs.set(key, def);
|
|
884
|
+
registerDef(def);
|
|
885
|
+
mergedDirty = true;
|
|
886
|
+
return derivedHandle(def, path);
|
|
887
|
+
},
|
|
888
|
+
subscribe(fn, selector, equals) {
|
|
889
|
+
const eq = equals ?? Object.is;
|
|
890
|
+
let hasPrev = false;
|
|
891
|
+
let prev;
|
|
892
|
+
const wrapper = selector
|
|
893
|
+
? () => {
|
|
894
|
+
const selected = selector(buildMerged());
|
|
895
|
+
if (!hasPrev || !eq(selected, prev)) {
|
|
896
|
+
hasPrev = true;
|
|
897
|
+
prev = selected;
|
|
898
|
+
fn(selected);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
: (current) => fn(current);
|
|
902
|
+
subscribers.add(wrapper);
|
|
903
|
+
wrapper(buildMerged());
|
|
904
|
+
return () => {
|
|
905
|
+
subscribers.delete(wrapper);
|
|
906
|
+
};
|
|
907
|
+
},
|
|
908
|
+
watch(selector, callback) {
|
|
909
|
+
let prev = selector(buildMerged());
|
|
910
|
+
const wrapper = () => {
|
|
911
|
+
const next = selector(buildMerged());
|
|
912
|
+
if (!Object.is(prev, next)) {
|
|
913
|
+
const old = prev;
|
|
914
|
+
prev = next;
|
|
915
|
+
callback(next, old);
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
watchers.add(wrapper);
|
|
919
|
+
return () => {
|
|
920
|
+
watchers.delete(wrapper);
|
|
921
|
+
};
|
|
922
|
+
},
|
|
923
|
+
/** Causal explanation for the current value at `path`. */
|
|
924
|
+
why(path) {
|
|
925
|
+
return whyAt(parseStorePath(path));
|
|
926
|
+
},
|
|
927
|
+
/** A printable causal chain for devtools / tests. */
|
|
928
|
+
explain(path) {
|
|
929
|
+
return renderExplanation(whyAt(parseStorePath(path)));
|
|
930
|
+
},
|
|
931
|
+
/** The recorded events, oldest first. */
|
|
932
|
+
get events() {
|
|
933
|
+
return events;
|
|
934
|
+
},
|
|
935
|
+
get version() {
|
|
936
|
+
return version;
|
|
937
|
+
},
|
|
938
|
+
canUndo() {
|
|
939
|
+
return undoStack.length > 0;
|
|
940
|
+
},
|
|
941
|
+
canRedo() {
|
|
942
|
+
return redoStack.length > 0;
|
|
943
|
+
},
|
|
944
|
+
undo() {
|
|
945
|
+
const prev = undoStack.pop();
|
|
946
|
+
if (prev === undefined)
|
|
947
|
+
return false;
|
|
948
|
+
redoStack.push(state);
|
|
949
|
+
restore(prev, 'undo');
|
|
950
|
+
return true;
|
|
951
|
+
},
|
|
952
|
+
redo() {
|
|
953
|
+
const next = redoStack.pop();
|
|
954
|
+
if (next === undefined)
|
|
955
|
+
return false;
|
|
956
|
+
undoStack.push(state);
|
|
957
|
+
restore(next, 'redo');
|
|
958
|
+
return true;
|
|
959
|
+
},
|
|
960
|
+
/** A JSON-safe snapshot of the current merged state. */
|
|
961
|
+
toJSON() {
|
|
962
|
+
return plainClone(buildMerged());
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
return store;
|
|
966
|
+
}
|