@pyreon/reactivity 0.1.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 +21 -0
- package/README.md +73 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +838 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +725 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +342 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +40 -0
- package/src/batch.ts +44 -0
- package/src/cell.ts +71 -0
- package/src/computed.ts +71 -0
- package/src/createSelector.ts +56 -0
- package/src/debug.ts +134 -0
- package/src/effect.ts +152 -0
- package/src/index.ts +15 -0
- package/src/reconcile.ts +98 -0
- package/src/resource.ts +66 -0
- package/src/scope.ts +80 -0
- package/src/signal.ts +125 -0
- package/src/store.ts +139 -0
- package/src/tests/batch.test.ts +69 -0
- package/src/tests/bind.test.ts +84 -0
- package/src/tests/branches.test.ts +343 -0
- package/src/tests/cell.test.ts +111 -0
- package/src/tests/computed.test.ts +146 -0
- package/src/tests/createSelector.test.ts +119 -0
- package/src/tests/debug.test.ts +196 -0
- package/src/tests/effect.test.ts +256 -0
- package/src/tests/resource.test.ts +133 -0
- package/src/tests/scope.test.ts +202 -0
- package/src/tests/signal.test.ts +120 -0
- package/src/tests/store.test.ts +136 -0
- package/src/tests/tracking.test.ts +158 -0
- package/src/tests/watch.test.ts +146 -0
- package/src/tracking.ts +103 -0
- package/src/watch.ts +69 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
function batch(fn) {
|
|
2
|
+
batchDepth++;
|
|
3
|
+
try {
|
|
4
|
+
fn();
|
|
5
|
+
} finally {
|
|
6
|
+
batchDepth--;
|
|
7
|
+
if (batchDepth === 0) {
|
|
8
|
+
const flush = pendingNotifications;
|
|
9
|
+
pendingNotifications = /* @__PURE__ */new Set();
|
|
10
|
+
for (const notify of flush) notify();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function isBatching() {
|
|
15
|
+
return batchDepth > 0;
|
|
16
|
+
}
|
|
17
|
+
function enqueuePendingNotification(notify) {
|
|
18
|
+
pendingNotifications.add(notify);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns a Promise that resolves after all currently-pending microtasks have flushed.
|
|
22
|
+
* Useful when you need to read the DOM after a batch of signal updates has settled.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* count.set(1); count.set(2)
|
|
26
|
+
* await nextTick()
|
|
27
|
+
* // DOM is now up-to-date
|
|
28
|
+
*/
|
|
29
|
+
function nextTick() {
|
|
30
|
+
return new Promise(resolve => queueMicrotask(resolve));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/cell.ts
|
|
35
|
+
/**
|
|
36
|
+
* Lightweight reactive cell — class-based alternative to signal().
|
|
37
|
+
*
|
|
38
|
+
* - 1 object allocation vs signal()'s 6 closures
|
|
39
|
+
* - Same API surface: peek(), set(), update(), subscribe(), listen()
|
|
40
|
+
* - NOT callable as a getter (no effect tracking) — use for fixed subscriptions
|
|
41
|
+
* - Methods on prototype, shared across all instances
|
|
42
|
+
* - Single-listener fast path: no Set allocated when ≤1 subscriber
|
|
43
|
+
*
|
|
44
|
+
* Use when you need reactive state but don't need automatic effect dependency tracking.
|
|
45
|
+
* Ideal for list item labels in keyed reconcilers where subscribe() is used directly.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
function cell(value) {
|
|
49
|
+
return new Cell(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/scope.ts
|
|
54
|
+
|
|
55
|
+
function getCurrentScope() {
|
|
56
|
+
return _currentScope;
|
|
57
|
+
}
|
|
58
|
+
function setCurrentScope(scope) {
|
|
59
|
+
_currentScope = scope;
|
|
60
|
+
}
|
|
61
|
+
/** Create a new EffectScope. */
|
|
62
|
+
function effectScope() {
|
|
63
|
+
return new EffectScope();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/tracking.ts
|
|
68
|
+
|
|
69
|
+
function setDepsCollector(collector) {
|
|
70
|
+
_depsCollector = collector;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Register the active effect as a subscriber of the given reactive source.
|
|
74
|
+
* The subscriber Set is created lazily on the host — sources read only outside
|
|
75
|
+
* effects never allocate a Set.
|
|
76
|
+
*/
|
|
77
|
+
function trackSubscriber(host) {
|
|
78
|
+
if (activeEffect) {
|
|
79
|
+
if (!host._s) host._s = /* @__PURE__ */new Set();
|
|
80
|
+
const subscribers = host._s;
|
|
81
|
+
subscribers.add(activeEffect);
|
|
82
|
+
if (_depsCollector) _depsCollector.push(subscribers);else {
|
|
83
|
+
let deps = effectDeps.get(activeEffect);
|
|
84
|
+
if (!deps) {
|
|
85
|
+
deps = /* @__PURE__ */new Set();
|
|
86
|
+
effectDeps.set(activeEffect, deps);
|
|
87
|
+
}
|
|
88
|
+
deps.add(subscribers);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Remove an effect from every subscriber set it was registered in,
|
|
94
|
+
* then clear its dep record. Call this before each re-run and on dispose.
|
|
95
|
+
*/
|
|
96
|
+
function cleanupEffect(fn) {
|
|
97
|
+
const deps = effectDeps.get(fn);
|
|
98
|
+
if (deps) {
|
|
99
|
+
for (const sub of deps) sub.delete(fn);
|
|
100
|
+
deps.clear();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function notifySubscribers(subscribers) {
|
|
104
|
+
if (subscribers.size === 0) return;
|
|
105
|
+
if (subscribers.size === 1) {
|
|
106
|
+
const sub = subscribers.values().next().value;
|
|
107
|
+
if (isBatching()) enqueuePendingNotification(sub);else sub();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (isBatching()) for (const sub of subscribers) enqueuePendingNotification(sub);else for (const sub of [...subscribers]) sub();
|
|
111
|
+
}
|
|
112
|
+
function withTracking(fn, compute) {
|
|
113
|
+
const prev = activeEffect;
|
|
114
|
+
activeEffect = fn;
|
|
115
|
+
try {
|
|
116
|
+
return compute();
|
|
117
|
+
} finally {
|
|
118
|
+
activeEffect = prev;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function runUntracked(fn) {
|
|
122
|
+
const prev = activeEffect;
|
|
123
|
+
activeEffect = null;
|
|
124
|
+
try {
|
|
125
|
+
return fn();
|
|
126
|
+
} finally {
|
|
127
|
+
activeEffect = prev;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/computed.ts
|
|
133
|
+
function computed(fn, options) {
|
|
134
|
+
let value;
|
|
135
|
+
let dirty = true;
|
|
136
|
+
let initialized = false;
|
|
137
|
+
let disposed = false;
|
|
138
|
+
const customEquals = options?.equals;
|
|
139
|
+
const host = {
|
|
140
|
+
_s: null
|
|
141
|
+
};
|
|
142
|
+
const recompute = () => {
|
|
143
|
+
if (disposed) return;
|
|
144
|
+
cleanupEffect(recompute);
|
|
145
|
+
if (customEquals) {
|
|
146
|
+
const next = withTracking(recompute, fn);
|
|
147
|
+
if (initialized && customEquals(value, next)) return;
|
|
148
|
+
value = next;
|
|
149
|
+
dirty = false;
|
|
150
|
+
initialized = true;
|
|
151
|
+
if (host._s) notifySubscribers(host._s);
|
|
152
|
+
} else {
|
|
153
|
+
dirty = true;
|
|
154
|
+
if (host._s) notifySubscribers(host._s);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const read = () => {
|
|
158
|
+
trackSubscriber(host);
|
|
159
|
+
if (dirty) {
|
|
160
|
+
value = withTracking(recompute, fn);
|
|
161
|
+
dirty = false;
|
|
162
|
+
initialized = true;
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
};
|
|
166
|
+
read.dispose = () => {
|
|
167
|
+
disposed = true;
|
|
168
|
+
cleanupEffect(recompute);
|
|
169
|
+
};
|
|
170
|
+
getCurrentScope()?.add({
|
|
171
|
+
dispose: read.dispose
|
|
172
|
+
});
|
|
173
|
+
return read;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
//#endregion
|
|
177
|
+
//#region src/effect.ts
|
|
178
|
+
|
|
179
|
+
function setErrorHandler(fn) {
|
|
180
|
+
_errorHandler = fn;
|
|
181
|
+
}
|
|
182
|
+
function effect(fn) {
|
|
183
|
+
const scope = getCurrentScope();
|
|
184
|
+
let disposed = false;
|
|
185
|
+
let isFirstRun = true;
|
|
186
|
+
let cleanup;
|
|
187
|
+
const runCleanup = () => {
|
|
188
|
+
if (typeof cleanup === "function") {
|
|
189
|
+
try {
|
|
190
|
+
cleanup();
|
|
191
|
+
} catch (err) {
|
|
192
|
+
_errorHandler(err);
|
|
193
|
+
}
|
|
194
|
+
cleanup = void 0;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const run = () => {
|
|
198
|
+
if (disposed) return;
|
|
199
|
+
runCleanup();
|
|
200
|
+
cleanupEffect(run);
|
|
201
|
+
try {
|
|
202
|
+
cleanup = withTracking(run, fn) || void 0;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
_errorHandler(err);
|
|
205
|
+
}
|
|
206
|
+
if (!isFirstRun) scope?.notifyEffectRan();
|
|
207
|
+
isFirstRun = false;
|
|
208
|
+
};
|
|
209
|
+
run();
|
|
210
|
+
const e = {
|
|
211
|
+
dispose() {
|
|
212
|
+
runCleanup();
|
|
213
|
+
disposed = true;
|
|
214
|
+
cleanupEffect(run);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
getCurrentScope()?.add(e);
|
|
218
|
+
return e;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Lightweight effect for DOM render bindings.
|
|
222
|
+
*
|
|
223
|
+
* Differences from `effect()`:
|
|
224
|
+
* - No EffectScope registration (caller owns the dispose lifecycle)
|
|
225
|
+
* - No error handler (errors propagate naturally)
|
|
226
|
+
* - No onUpdate notification
|
|
227
|
+
* - Deps stored in a local array instead of the global WeakMap — faster
|
|
228
|
+
* creation and disposal (~200ns saved per effect vs WeakMap path)
|
|
229
|
+
*
|
|
230
|
+
* Returns a dispose function (not an Effect object — saves 1 allocation).
|
|
231
|
+
*/
|
|
232
|
+
/**
|
|
233
|
+
* Static-dep binding — compiler helper for template expressions.
|
|
234
|
+
*
|
|
235
|
+
* Like renderEffect but assumes dependencies never change (true for all
|
|
236
|
+
* compiler-emitted template bindings like `_tpl()` text/attribute updates).
|
|
237
|
+
*
|
|
238
|
+
* Tracks dependencies only on the first run. Re-runs skip cleanup, re-tracking,
|
|
239
|
+
* and tracking context save/restore entirely — just calls `fn()` directly.
|
|
240
|
+
*
|
|
241
|
+
* Per re-run savings vs renderEffect:
|
|
242
|
+
* - No deps iteration + Set.delete (cleanup)
|
|
243
|
+
* - No setDepsCollector + withTracking (re-registration)
|
|
244
|
+
* - Signal reads hit `if (activeEffect)` null check → instant return
|
|
245
|
+
*/
|
|
246
|
+
function _bind(fn) {
|
|
247
|
+
const deps = [];
|
|
248
|
+
let disposed = false;
|
|
249
|
+
const run = () => {
|
|
250
|
+
if (disposed) return;
|
|
251
|
+
fn();
|
|
252
|
+
};
|
|
253
|
+
setDepsCollector(deps);
|
|
254
|
+
withTracking(run, fn);
|
|
255
|
+
setDepsCollector(null);
|
|
256
|
+
const dispose = () => {
|
|
257
|
+
if (disposed) return;
|
|
258
|
+
disposed = true;
|
|
259
|
+
for (const s of deps) s.delete(run);
|
|
260
|
+
deps.length = 0;
|
|
261
|
+
};
|
|
262
|
+
getCurrentScope()?.add({
|
|
263
|
+
dispose
|
|
264
|
+
});
|
|
265
|
+
return dispose;
|
|
266
|
+
}
|
|
267
|
+
function renderEffect(fn) {
|
|
268
|
+
const deps = [];
|
|
269
|
+
let disposed = false;
|
|
270
|
+
const run = () => {
|
|
271
|
+
if (disposed) return;
|
|
272
|
+
for (const s of deps) s.delete(run);
|
|
273
|
+
deps.length = 0;
|
|
274
|
+
setDepsCollector(deps);
|
|
275
|
+
withTracking(run, fn);
|
|
276
|
+
setDepsCollector(null);
|
|
277
|
+
};
|
|
278
|
+
run();
|
|
279
|
+
const dispose = () => {
|
|
280
|
+
if (disposed) return;
|
|
281
|
+
disposed = true;
|
|
282
|
+
for (const s of deps) s.delete(run);
|
|
283
|
+
deps.length = 0;
|
|
284
|
+
};
|
|
285
|
+
getCurrentScope()?.add({
|
|
286
|
+
dispose
|
|
287
|
+
});
|
|
288
|
+
return dispose;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/createSelector.ts
|
|
293
|
+
/**
|
|
294
|
+
* Create an equality selector — returns a reactive predicate that is true
|
|
295
|
+
* only for the currently selected value.
|
|
296
|
+
*
|
|
297
|
+
* Unlike a plain `() => source() === value`, this only triggers the TWO
|
|
298
|
+
* affected subscribers (deselected + newly selected) instead of ALL
|
|
299
|
+
* subscribers, making selection O(1) regardless of list size.
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* const isSelected = createSelector(selectedId)
|
|
303
|
+
* // In each row:
|
|
304
|
+
* class: () => (isSelected(row.id) ? "selected" : "")
|
|
305
|
+
*/
|
|
306
|
+
function createSelector(source) {
|
|
307
|
+
const subs = /* @__PURE__ */new Map();
|
|
308
|
+
let current;
|
|
309
|
+
let initialized = false;
|
|
310
|
+
effect(() => {
|
|
311
|
+
const next = source();
|
|
312
|
+
if (!initialized) {
|
|
313
|
+
initialized = true;
|
|
314
|
+
current = next;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (Object.is(next, current)) return;
|
|
318
|
+
const old = current;
|
|
319
|
+
current = next;
|
|
320
|
+
const oldBucket = subs.get(old);
|
|
321
|
+
const newBucket = subs.get(next);
|
|
322
|
+
if (oldBucket) for (const fn of [...oldBucket]) fn();
|
|
323
|
+
if (newBucket) for (const fn of [...newBucket]) fn();
|
|
324
|
+
});
|
|
325
|
+
const hosts = /* @__PURE__ */new Map();
|
|
326
|
+
return value => {
|
|
327
|
+
let host = hosts.get(value);
|
|
328
|
+
if (!host) {
|
|
329
|
+
let bucket = subs.get(value);
|
|
330
|
+
if (!bucket) {
|
|
331
|
+
bucket = /* @__PURE__ */new Set();
|
|
332
|
+
subs.set(value, bucket);
|
|
333
|
+
}
|
|
334
|
+
host = {
|
|
335
|
+
_s: bucket
|
|
336
|
+
};
|
|
337
|
+
hosts.set(value, host);
|
|
338
|
+
}
|
|
339
|
+
trackSubscriber(host);
|
|
340
|
+
return Object.is(current, value);
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/debug.ts
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Register a listener that fires on every signal write.
|
|
349
|
+
* Returns a dispose function.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* const dispose = onSignalUpdate(e => {
|
|
353
|
+
* console.log(`${e.name ?? 'anonymous'}: ${e.prev} → ${e.next}`)
|
|
354
|
+
* })
|
|
355
|
+
*/
|
|
356
|
+
function onSignalUpdate(listener) {
|
|
357
|
+
if (!_traceListeners) _traceListeners = [];
|
|
358
|
+
_traceListeners.push(listener);
|
|
359
|
+
return () => {
|
|
360
|
+
if (!_traceListeners) return;
|
|
361
|
+
_traceListeners = _traceListeners.filter(l => l !== listener);
|
|
362
|
+
if (_traceListeners.length === 0) _traceListeners = null;
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/** @internal — called from signal.set() when tracing is active */
|
|
366
|
+
function _notifyTraceListeners(sig, prev, next) {
|
|
367
|
+
if (!_traceListeners) return;
|
|
368
|
+
const event = {
|
|
369
|
+
signal: sig,
|
|
370
|
+
name: sig.label,
|
|
371
|
+
prev,
|
|
372
|
+
next,
|
|
373
|
+
stack: (/* @__PURE__ */new Error()).stack ?? "",
|
|
374
|
+
timestamp: performance.now()
|
|
375
|
+
};
|
|
376
|
+
for (const l of _traceListeners) l(event);
|
|
377
|
+
}
|
|
378
|
+
/** Check if any trace listeners are active (fast path for signal.set) */
|
|
379
|
+
function isTracing() {
|
|
380
|
+
return _traceListeners !== null;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Trace the next signal update. Logs which signals fire and what changed.
|
|
384
|
+
* Call before triggering a state change to see what updates and why.
|
|
385
|
+
*
|
|
386
|
+
* @example
|
|
387
|
+
* why()
|
|
388
|
+
* count.set(5)
|
|
389
|
+
* // Console: [pyreon:why] "count": 3 → 5 (2 subscribers)
|
|
390
|
+
*/
|
|
391
|
+
function why() {
|
|
392
|
+
if (_whyActive) return;
|
|
393
|
+
_whyActive = true;
|
|
394
|
+
_whyLog = [];
|
|
395
|
+
const dispose = onSignalUpdate(e => {
|
|
396
|
+
const _subCount = e.signal._s?.size ?? 0;
|
|
397
|
+
const _name = e.name ? `"${e.name}"` : "(anonymous signal)";
|
|
398
|
+
console.log(`[pyreon:why] ${_name}: ${JSON.stringify(e.prev)} → ${JSON.stringify(e.next)} (${_subCount} subscriber${_subCount === 1 ? "" : "s"})`);
|
|
399
|
+
_whyLog.push({
|
|
400
|
+
name: e.name,
|
|
401
|
+
prev: e.prev,
|
|
402
|
+
next: e.next
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
queueMicrotask(() => {
|
|
406
|
+
dispose();
|
|
407
|
+
if (_whyLog.length === 0) console.log("[pyreon:why] No signal updates detected");
|
|
408
|
+
_whyActive = false;
|
|
409
|
+
_whyLog = [];
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Print a signal's current state to the console in a readable format.
|
|
414
|
+
*
|
|
415
|
+
* @example
|
|
416
|
+
* const count = signal(42, { name: "count" })
|
|
417
|
+
* inspectSignal(count)
|
|
418
|
+
* // Console:
|
|
419
|
+
* // 🔍 Signal "count"
|
|
420
|
+
* // value: 42
|
|
421
|
+
* // subscribers: 3
|
|
422
|
+
*/
|
|
423
|
+
function inspectSignal(sig) {
|
|
424
|
+
const info = sig.debug();
|
|
425
|
+
console.group(`🔍 Signal ${info.name ? `"${info.name}"` : "(anonymous)"}`);
|
|
426
|
+
console.log("value:", info.value);
|
|
427
|
+
console.log("subscribers:", info.subscriberCount);
|
|
428
|
+
console.groupEnd();
|
|
429
|
+
return info;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/signal.ts
|
|
434
|
+
function _peek() {
|
|
435
|
+
return this._v;
|
|
436
|
+
}
|
|
437
|
+
function _set(newValue) {
|
|
438
|
+
if (Object.is(this._v, newValue)) return;
|
|
439
|
+
const prev = this._v;
|
|
440
|
+
this._v = newValue;
|
|
441
|
+
if (isTracing()) _notifyTraceListeners(this, prev, newValue);
|
|
442
|
+
if (this._s) notifySubscribers(this._s);
|
|
443
|
+
}
|
|
444
|
+
function _update(fn) {
|
|
445
|
+
_set.call(this, fn(this._v));
|
|
446
|
+
}
|
|
447
|
+
function _subscribe(listener) {
|
|
448
|
+
if (!this._s) this._s = /* @__PURE__ */new Set();
|
|
449
|
+
this._s.add(listener);
|
|
450
|
+
return () => this._s?.delete(listener);
|
|
451
|
+
}
|
|
452
|
+
function _debug() {
|
|
453
|
+
return {
|
|
454
|
+
name: this._n,
|
|
455
|
+
value: this._v,
|
|
456
|
+
subscriberCount: this._s?.size ?? 0
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Create a reactive signal.
|
|
461
|
+
*
|
|
462
|
+
* Only 1 closure is allocated (the read function). State is stored as
|
|
463
|
+
* properties on the function object (_v, _s) and methods (peek, set,
|
|
464
|
+
* update, subscribe) are shared across all signals — not per-signal closures.
|
|
465
|
+
*/
|
|
466
|
+
function signal(initialValue, options) {
|
|
467
|
+
const read = () => {
|
|
468
|
+
trackSubscriber(read);
|
|
469
|
+
return read._v;
|
|
470
|
+
};
|
|
471
|
+
read._v = initialValue;
|
|
472
|
+
read._s = null;
|
|
473
|
+
read._n = options?.name;
|
|
474
|
+
read.peek = _peek;
|
|
475
|
+
read.set = _set;
|
|
476
|
+
read.update = _update;
|
|
477
|
+
read.subscribe = _subscribe;
|
|
478
|
+
read.debug = _debug;
|
|
479
|
+
Object.defineProperty(read, "label", _labelDescriptor);
|
|
480
|
+
return read;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/store.ts
|
|
485
|
+
/**
|
|
486
|
+
* createStore — deep reactive Proxy store.
|
|
487
|
+
*
|
|
488
|
+
* Wraps a plain object/array in a Proxy that creates a fine-grained signal for
|
|
489
|
+
* every property. Direct mutations (`store.count++`, `store.items[0].label = "x"`)
|
|
490
|
+
* trigger only the signals for the mutated properties — not the whole tree.
|
|
491
|
+
*
|
|
492
|
+
* @example
|
|
493
|
+
* const state = createStore({ count: 0, items: [{ id: 1, text: "hello" }] })
|
|
494
|
+
*
|
|
495
|
+
* effect(() => console.log(state.count)) // tracks state.count only
|
|
496
|
+
* state.count++ // only the count effect re-runs
|
|
497
|
+
* state.items[0].text = "world" // only text-tracking effects re-run
|
|
498
|
+
*/
|
|
499
|
+
|
|
500
|
+
/** Returns true if the value is a createStore proxy. */
|
|
501
|
+
function isStore(value) {
|
|
502
|
+
return value !== null && typeof value === "object" && value[IS_STORE] === true;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Create a deep reactive store from a plain object or array.
|
|
506
|
+
* Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
|
|
507
|
+
*/
|
|
508
|
+
function createStore(initial) {
|
|
509
|
+
return wrap(initial);
|
|
510
|
+
}
|
|
511
|
+
function wrap(raw) {
|
|
512
|
+
const cached = proxyCache.get(raw);
|
|
513
|
+
if (cached) return cached;
|
|
514
|
+
const propSignals = /* @__PURE__ */new Map();
|
|
515
|
+
const isArray = Array.isArray(raw);
|
|
516
|
+
const lengthSig = isArray ? signal(raw.length) : null;
|
|
517
|
+
function getOrCreateSignal(key) {
|
|
518
|
+
if (!propSignals.has(key)) propSignals.set(key, signal(raw[key]));
|
|
519
|
+
return propSignals.get(key);
|
|
520
|
+
}
|
|
521
|
+
const proxy = new Proxy(raw, {
|
|
522
|
+
get(target, key) {
|
|
523
|
+
if (key === IS_STORE) return true;
|
|
524
|
+
if (typeof key === "symbol") return target[key];
|
|
525
|
+
if (isArray && key === "length") return lengthSig?.();
|
|
526
|
+
if (!Object.hasOwn(target, key)) return target[key];
|
|
527
|
+
const value = getOrCreateSignal(key)();
|
|
528
|
+
if (value !== null && typeof value === "object") return wrap(value);
|
|
529
|
+
return value;
|
|
530
|
+
},
|
|
531
|
+
set(target, key, value) {
|
|
532
|
+
if (typeof key === "symbol") {
|
|
533
|
+
target[key] = value;
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
const prevLength = isArray ? target.length : 0;
|
|
537
|
+
target[key] = value;
|
|
538
|
+
if (isArray && key === "length") {
|
|
539
|
+
lengthSig?.set(value);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
if (propSignals.has(key)) propSignals.get(key)?.set(value);else propSignals.set(key, signal(value));
|
|
543
|
+
if (isArray && target.length !== prevLength) lengthSig?.set(target.length);
|
|
544
|
+
return true;
|
|
545
|
+
},
|
|
546
|
+
deleteProperty(target, key) {
|
|
547
|
+
delete target[key];
|
|
548
|
+
if (typeof key !== "symbol" && propSignals.has(key)) {
|
|
549
|
+
propSignals.get(key)?.set(void 0);
|
|
550
|
+
propSignals.delete(key);
|
|
551
|
+
}
|
|
552
|
+
if (isArray) lengthSig?.set(target.length);
|
|
553
|
+
return true;
|
|
554
|
+
},
|
|
555
|
+
has(target, key) {
|
|
556
|
+
return Reflect.has(target, key);
|
|
557
|
+
},
|
|
558
|
+
ownKeys(target) {
|
|
559
|
+
return Reflect.ownKeys(target);
|
|
560
|
+
},
|
|
561
|
+
getOwnPropertyDescriptor(target, key) {
|
|
562
|
+
return Reflect.getOwnPropertyDescriptor(target, key);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
proxyCache.set(raw, proxy);
|
|
566
|
+
return proxy;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
//#endregion
|
|
570
|
+
//#region src/reconcile.ts
|
|
571
|
+
/**
|
|
572
|
+
* reconcile — surgically diff new state into an existing createStore proxy.
|
|
573
|
+
*
|
|
574
|
+
* Instead of replacing the store root (which would trigger all downstream effects),
|
|
575
|
+
* reconcile walks both the new value and the store in parallel and only calls
|
|
576
|
+
* `.set()` on signals whose value actually changed.
|
|
577
|
+
*
|
|
578
|
+
* Ideal for applying API responses to a long-lived store:
|
|
579
|
+
*
|
|
580
|
+
* @example
|
|
581
|
+
* const state = createStore({ user: { name: "Alice", age: 30 }, items: [] })
|
|
582
|
+
*
|
|
583
|
+
* // API response arrives:
|
|
584
|
+
* reconcile({ user: { name: "Alice", age: 31 }, items: [{ id: 1 }] }, state)
|
|
585
|
+
* // → only state.user.age signal fires (name unchanged)
|
|
586
|
+
* // → state.items[0] is newly created
|
|
587
|
+
*
|
|
588
|
+
* Arrays are reconciled by index — elements at the same index are recursively
|
|
589
|
+
* diffed rather than replaced wholesale. Excess old elements are removed.
|
|
590
|
+
*/
|
|
591
|
+
function reconcile(source, target) {
|
|
592
|
+
_reconcileInner(source, target, /* @__PURE__ */new WeakSet());
|
|
593
|
+
}
|
|
594
|
+
function _reconcileInner(source, target, seen) {
|
|
595
|
+
if (seen.has(source)) return;
|
|
596
|
+
seen.add(source);
|
|
597
|
+
if (Array.isArray(source) && Array.isArray(target)) _reconcileArray(source, target, seen);else _reconcileObject(source, target, seen);
|
|
598
|
+
}
|
|
599
|
+
function _reconcileArray(source, target, seen) {
|
|
600
|
+
const targetLen = target.length;
|
|
601
|
+
const sourceLen = source.length;
|
|
602
|
+
for (let i = 0; i < sourceLen; i++) {
|
|
603
|
+
const sv = source[i];
|
|
604
|
+
const tv = target[i];
|
|
605
|
+
if (i < targetLen && sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") _reconcileInner(sv, tv, seen);else target[i] = sv;
|
|
606
|
+
}
|
|
607
|
+
if (targetLen > sourceLen) target.length = sourceLen;
|
|
608
|
+
}
|
|
609
|
+
function _reconcileObject(source, target, seen) {
|
|
610
|
+
const sourceKeys = Object.keys(source);
|
|
611
|
+
const targetKeys = new Set(Object.keys(target));
|
|
612
|
+
for (const key of sourceKeys) {
|
|
613
|
+
const sv = source[key];
|
|
614
|
+
const tv = target[key];
|
|
615
|
+
if (sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") {
|
|
616
|
+
if (isStore(tv)) _reconcileInner(sv, tv, seen);else target[key] = sv;
|
|
617
|
+
} else target[key] = sv;
|
|
618
|
+
targetKeys.delete(key);
|
|
619
|
+
}
|
|
620
|
+
for (const key of targetKeys) delete target[key];
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/resource.ts
|
|
625
|
+
/**
|
|
626
|
+
* Async data primitive. Fetches data reactively whenever `source()` changes.
|
|
627
|
+
*
|
|
628
|
+
* @example
|
|
629
|
+
* const userId = signal(1)
|
|
630
|
+
* const user = createResource(userId, (id) => fetchUser(id))
|
|
631
|
+
* // user.data() — the fetched user (undefined while loading)
|
|
632
|
+
* // user.loading() — true while in flight
|
|
633
|
+
* // user.error() — last error
|
|
634
|
+
*/
|
|
635
|
+
function createResource(source, fetcher) {
|
|
636
|
+
const data = signal(void 0);
|
|
637
|
+
const loading = signal(false);
|
|
638
|
+
const error = signal(void 0);
|
|
639
|
+
let requestId = 0;
|
|
640
|
+
const doFetch = param => {
|
|
641
|
+
const id = ++requestId;
|
|
642
|
+
loading.set(true);
|
|
643
|
+
error.set(void 0);
|
|
644
|
+
fetcher(param).then(result => {
|
|
645
|
+
if (id !== requestId) return;
|
|
646
|
+
data.set(result);
|
|
647
|
+
loading.set(false);
|
|
648
|
+
}).catch(err => {
|
|
649
|
+
if (id !== requestId) return;
|
|
650
|
+
error.set(err);
|
|
651
|
+
loading.set(false);
|
|
652
|
+
});
|
|
653
|
+
};
|
|
654
|
+
effect(() => {
|
|
655
|
+
const param = source();
|
|
656
|
+
runUntracked(() => doFetch(param));
|
|
657
|
+
});
|
|
658
|
+
return {
|
|
659
|
+
data,
|
|
660
|
+
loading,
|
|
661
|
+
error,
|
|
662
|
+
refetch() {
|
|
663
|
+
runUntracked(() => doFetch(source()));
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
//#endregion
|
|
669
|
+
//#region src/watch.ts
|
|
670
|
+
/**
|
|
671
|
+
* Watch a reactive source and run a callback whenever it changes.
|
|
672
|
+
*
|
|
673
|
+
* Returns a stop function that disposes the watcher.
|
|
674
|
+
*
|
|
675
|
+
* The callback receives (newValue, oldValue). On the first call (when
|
|
676
|
+
* `immediate` is true) oldValue is `undefined`.
|
|
677
|
+
*
|
|
678
|
+
* The callback may return a cleanup function that is called before each
|
|
679
|
+
* re-run and on stop — useful for cancelling async work.
|
|
680
|
+
*
|
|
681
|
+
* @example
|
|
682
|
+
* const stop = watch(
|
|
683
|
+
* () => userId(),
|
|
684
|
+
* async (id, prev) => {
|
|
685
|
+
* const data = await fetch(`/api/user/${id}`)
|
|
686
|
+
* setUser(await data.json())
|
|
687
|
+
* },
|
|
688
|
+
* )
|
|
689
|
+
* // Later: stop()
|
|
690
|
+
*/
|
|
691
|
+
function watch(source, callback, opts = {}) {
|
|
692
|
+
let oldVal;
|
|
693
|
+
let isFirst = true;
|
|
694
|
+
let cleanupFn;
|
|
695
|
+
const e = effect(() => {
|
|
696
|
+
const newVal = source();
|
|
697
|
+
if (isFirst) {
|
|
698
|
+
isFirst = false;
|
|
699
|
+
oldVal = newVal;
|
|
700
|
+
if (opts.immediate) {
|
|
701
|
+
const result = callback(newVal, void 0);
|
|
702
|
+
if (typeof result === "function") cleanupFn = result;
|
|
703
|
+
}
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
if (cleanupFn) {
|
|
707
|
+
cleanupFn();
|
|
708
|
+
cleanupFn = void 0;
|
|
709
|
+
}
|
|
710
|
+
const result = callback(newVal, oldVal);
|
|
711
|
+
if (typeof result === "function") cleanupFn = result;
|
|
712
|
+
oldVal = newVal;
|
|
713
|
+
});
|
|
714
|
+
return () => {
|
|
715
|
+
e.dispose();
|
|
716
|
+
if (cleanupFn) {
|
|
717
|
+
cleanupFn();
|
|
718
|
+
cleanupFn = void 0;
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
//#endregion
|
|
724
|
+
export { Cell, EffectScope, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onSignalUpdate, reconcile, renderEffect, runUntracked, setCurrentScope, setErrorHandler, signal, watch, why };
|
|
725
|
+
//# sourceMappingURL=index.d.ts.map
|