@cplieger/reactive 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +19 -0
- package/.htmlvalidate.json +28 -0
- package/.stylelintrc.json +67 -0
- package/LICENSE +692 -0
- package/README.md +120 -0
- package/cliff.toml +37 -0
- package/eslint.config.base.mjs +187 -0
- package/jsr.json +22 -0
- package/package.json +35 -0
- package/src/index.ts +25 -0
- package/src/reconcile-tree.ts +169 -0
- package/src/reconcile.ts +55 -0
- package/src/signal.ts +687 -0
- package/src/store.ts +159 -0
package/src/signal.ts
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
// Reactive signals: signal<T>, effect (with cleanup), batch, computed, untracked.
|
|
2
|
+
// Preact-style doubly-linked source/target edges, pull-based glitch-free refresh,
|
|
3
|
+
// global epoch + per-node version fast-skip, bitfield flags.
|
|
4
|
+
// GPL-3.0-or-later.
|
|
5
|
+
|
|
6
|
+
const SIGNAL_BRAND = Symbol("signal");
|
|
7
|
+
const COMPUTED_BRAND = Symbol("computed");
|
|
8
|
+
|
|
9
|
+
// --- Bitfield flags ---
|
|
10
|
+
const DIRTY = 1;
|
|
11
|
+
const CHECK = 2;
|
|
12
|
+
const RUNNING = 4;
|
|
13
|
+
const DISPOSED = 8;
|
|
14
|
+
const HAS_ERROR = 16;
|
|
15
|
+
const TRACKING = 32;
|
|
16
|
+
const NOTIFIED = 64;
|
|
17
|
+
|
|
18
|
+
// --- Linked-list Node for source↔target edges ---
|
|
19
|
+
interface DepNode {
|
|
20
|
+
_source: SourceNode;
|
|
21
|
+
_prevSource: DepNode | undefined;
|
|
22
|
+
_nextSource: DepNode | undefined;
|
|
23
|
+
_target: TargetNode;
|
|
24
|
+
_prevTarget: DepNode | undefined;
|
|
25
|
+
_nextTarget: DepNode | undefined;
|
|
26
|
+
_version: number;
|
|
27
|
+
_rollbackNode: DepNode | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Anything that can be depended on (signal or computed)
|
|
31
|
+
interface SourceNode {
|
|
32
|
+
_value: unknown;
|
|
33
|
+
_version: number;
|
|
34
|
+
_node: DepNode | undefined;
|
|
35
|
+
_targets: DepNode | undefined;
|
|
36
|
+
_isComputed: boolean; // discriminator
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Anything that can depend on sources (computed or effect)
|
|
40
|
+
interface TargetNode {
|
|
41
|
+
_flags: number;
|
|
42
|
+
_sources: DepNode | undefined;
|
|
43
|
+
_isComputed: boolean; // discriminator: true=computed, false=effect
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Computed is BOTH source and target (same object)
|
|
47
|
+
interface ComputedNode extends SourceNode, TargetNode {
|
|
48
|
+
_isComputed: true;
|
|
49
|
+
_fn: () => unknown;
|
|
50
|
+
_equals: ((a: unknown, b: unknown) => boolean) | undefined;
|
|
51
|
+
_globalVersion: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface EffectNode {
|
|
55
|
+
_fn: (() => Cleanup) | undefined;
|
|
56
|
+
_cleanup: Cleanup;
|
|
57
|
+
_flags: number;
|
|
58
|
+
_sources: DepNode | undefined;
|
|
59
|
+
_nextBatchedEffect: EffectNode | undefined;
|
|
60
|
+
_isComputed: false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Globals ---
|
|
64
|
+
let evalContext: TargetNode | undefined;
|
|
65
|
+
let batchDepth = 0;
|
|
66
|
+
let batchedEffect: EffectNode | undefined;
|
|
67
|
+
let batchIteration = 0;
|
|
68
|
+
let globalVersion = 0;
|
|
69
|
+
|
|
70
|
+
// --- Effect error handler ---
|
|
71
|
+
/** Cleanup function returned by an effect callback, called before re-execution or disposal. */
|
|
72
|
+
export type Cleanup = undefined | (() => void);
|
|
73
|
+
|
|
74
|
+
/** Handler invoked when an effect throws an error. */
|
|
75
|
+
export type EffectErrorHandler = (error: unknown) => void;
|
|
76
|
+
let effectErrorHandler: EffectErrorHandler = (e) => {
|
|
77
|
+
console.error("effect error:", e);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function safeCallHandler(e: unknown): void {
|
|
81
|
+
try {
|
|
82
|
+
effectErrorHandler(e);
|
|
83
|
+
} catch {
|
|
84
|
+
// swallow
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Set a global error handler for effect errors. Returns the previous handler. */
|
|
89
|
+
export function setEffectErrorHandler(handler: EffectErrorHandler): EffectErrorHandler {
|
|
90
|
+
const prev = effectErrorHandler;
|
|
91
|
+
effectErrorHandler = handler;
|
|
92
|
+
return prev;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Dependency tracking ---
|
|
96
|
+
function addDependency(source: SourceNode): DepNode | undefined {
|
|
97
|
+
if (evalContext === undefined) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
let node = source._node;
|
|
101
|
+
if (node?._target !== evalContext) {
|
|
102
|
+
// Create a new node
|
|
103
|
+
node = {
|
|
104
|
+
_version: 0,
|
|
105
|
+
_source: source,
|
|
106
|
+
_prevSource: undefined,
|
|
107
|
+
_nextSource: undefined,
|
|
108
|
+
_target: evalContext,
|
|
109
|
+
_prevTarget: undefined,
|
|
110
|
+
_nextTarget: undefined,
|
|
111
|
+
_rollbackNode: source._node, // F3: save for rollback
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Link into target's sources (prepend)
|
|
115
|
+
const evalSources = evalContext._sources;
|
|
116
|
+
if (evalSources !== undefined) {
|
|
117
|
+
evalSources._prevSource = node;
|
|
118
|
+
}
|
|
119
|
+
node._nextSource = evalSources;
|
|
120
|
+
evalContext._sources = node;
|
|
121
|
+
|
|
122
|
+
// Link into source's targets (prepend)
|
|
123
|
+
const sourceTargets = source._targets;
|
|
124
|
+
if (sourceTargets !== undefined) {
|
|
125
|
+
sourceTargets._prevTarget = node;
|
|
126
|
+
}
|
|
127
|
+
node._nextTarget = sourceTargets;
|
|
128
|
+
source._targets = node;
|
|
129
|
+
|
|
130
|
+
source._node = node;
|
|
131
|
+
return node;
|
|
132
|
+
} else if (node._version === -1) {
|
|
133
|
+
// Recycle existing node
|
|
134
|
+
node._version = 0;
|
|
135
|
+
|
|
136
|
+
// Move to head of sources if not already there
|
|
137
|
+
if (node._prevSource !== undefined) {
|
|
138
|
+
node._prevSource._nextSource = node._nextSource;
|
|
139
|
+
if (node._nextSource !== undefined) {
|
|
140
|
+
node._nextSource._prevSource = node._prevSource;
|
|
141
|
+
}
|
|
142
|
+
node._prevSource = undefined;
|
|
143
|
+
const evalSources = evalContext._sources;
|
|
144
|
+
if (evalSources !== undefined) {
|
|
145
|
+
evalSources._prevSource = node;
|
|
146
|
+
}
|
|
147
|
+
node._nextSource = evalSources;
|
|
148
|
+
evalContext._sources = node;
|
|
149
|
+
}
|
|
150
|
+
return node;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Source preparation/cleanup for eval cycle ---
|
|
156
|
+
function prepareSources(target: TargetNode): void {
|
|
157
|
+
let node = target._sources;
|
|
158
|
+
while (node !== undefined) {
|
|
159
|
+
const rollback = node._source._node;
|
|
160
|
+
if (rollback !== undefined) {
|
|
161
|
+
node._rollbackNode = rollback;
|
|
162
|
+
}
|
|
163
|
+
node._source._node = node;
|
|
164
|
+
node._version = -1;
|
|
165
|
+
node = node._nextSource;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cleanupSources(target: TargetNode): void {
|
|
170
|
+
let node = target._sources;
|
|
171
|
+
let head = target._sources;
|
|
172
|
+
while (node !== undefined) {
|
|
173
|
+
const next = node._nextSource;
|
|
174
|
+
if (node._version === -1) {
|
|
175
|
+
// Not re-used — unlink from source's targets
|
|
176
|
+
if (node._prevTarget !== undefined) {
|
|
177
|
+
node._prevTarget._nextTarget = node._nextTarget;
|
|
178
|
+
} else {
|
|
179
|
+
node._source._targets = node._nextTarget;
|
|
180
|
+
}
|
|
181
|
+
if (node._nextTarget !== undefined) {
|
|
182
|
+
node._nextTarget._prevTarget = node._prevTarget;
|
|
183
|
+
}
|
|
184
|
+
// Remove from target's sources
|
|
185
|
+
if (node._prevSource !== undefined) {
|
|
186
|
+
node._prevSource._nextSource = node._nextSource;
|
|
187
|
+
} else {
|
|
188
|
+
head = node._nextSource;
|
|
189
|
+
}
|
|
190
|
+
if (node._nextSource !== undefined) {
|
|
191
|
+
node._nextSource._prevSource = node._prevSource;
|
|
192
|
+
}
|
|
193
|
+
// Restore rollback
|
|
194
|
+
node._source._node = node._rollbackNode;
|
|
195
|
+
node._rollbackNode = undefined;
|
|
196
|
+
} else {
|
|
197
|
+
// Kept — restore rollback pointer (F3)
|
|
198
|
+
node._source._node = node._rollbackNode;
|
|
199
|
+
node._rollbackNode = undefined;
|
|
200
|
+
}
|
|
201
|
+
node = next;
|
|
202
|
+
}
|
|
203
|
+
target._sources = head;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Notification propagation ---
|
|
207
|
+
function notifyTargets(source: SourceNode): void {
|
|
208
|
+
let node = source._targets;
|
|
209
|
+
while (node !== undefined) {
|
|
210
|
+
const target = node._target;
|
|
211
|
+
if (target._isComputed) {
|
|
212
|
+
// It's a ComputedNode
|
|
213
|
+
if (!(target._flags & (NOTIFIED | RUNNING))) {
|
|
214
|
+
target._flags |= DIRTY | NOTIFIED;
|
|
215
|
+
notifyTargets(target as unknown as SourceNode);
|
|
216
|
+
target._flags &= ~NOTIFIED;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// It's an EffectNode
|
|
220
|
+
if (!(target._flags & NOTIFIED)) {
|
|
221
|
+
target._flags |= NOTIFIED;
|
|
222
|
+
const eff = target as unknown as EffectNode;
|
|
223
|
+
eff._nextBatchedEffect = batchedEffect;
|
|
224
|
+
batchedEffect = eff;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
node = node._nextTarget;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Staleness check ---
|
|
232
|
+
function needsToRecompute(target: TargetNode): boolean {
|
|
233
|
+
let node = target._sources;
|
|
234
|
+
while (node !== undefined) {
|
|
235
|
+
const source = node._source;
|
|
236
|
+
if (source._isComputed) {
|
|
237
|
+
const comp = source as unknown as ComputedNode;
|
|
238
|
+
if (comp._flags & (DIRTY | CHECK)) {
|
|
239
|
+
refreshComputed(comp);
|
|
240
|
+
}
|
|
241
|
+
if (comp._flags & HAS_ERROR) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (source._version !== node._version) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
node = node._nextSource;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Computed refresh (pull-based, depth-first) ---
|
|
254
|
+
function refreshComputed(comp: ComputedNode): void {
|
|
255
|
+
if (comp._flags & RUNNING) {
|
|
256
|
+
throw new Error("Cycle detected");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Fast-path: nothing changed globally since last check
|
|
260
|
+
if (comp._globalVersion === globalVersion && !(comp._flags & (DIRTY | CHECK))) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
comp._globalVersion = globalVersion;
|
|
264
|
+
|
|
265
|
+
// If not dirty but CHECK, verify sources first
|
|
266
|
+
if (!(comp._flags & DIRTY) && comp._flags & CHECK) {
|
|
267
|
+
let sn = comp._sources;
|
|
268
|
+
while (sn !== undefined) {
|
|
269
|
+
const src = sn._source;
|
|
270
|
+
if (src._isComputed) {
|
|
271
|
+
const srcComp = src as unknown as ComputedNode;
|
|
272
|
+
if (srcComp._flags & (DIRTY | CHECK)) {
|
|
273
|
+
refreshComputed(srcComp);
|
|
274
|
+
}
|
|
275
|
+
if (srcComp._flags & HAS_ERROR) {
|
|
276
|
+
comp._flags |= DIRTY;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (src._version !== sn._version) {
|
|
281
|
+
comp._flags |= DIRTY;
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
sn = sn._nextSource;
|
|
285
|
+
}
|
|
286
|
+
if (!(comp._flags & DIRTY)) {
|
|
287
|
+
comp._flags &= ~CHECK;
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const prevContext = evalContext;
|
|
293
|
+
evalContext = comp;
|
|
294
|
+
const prevFlags = comp._flags;
|
|
295
|
+
comp._flags = RUNNING | TRACKING;
|
|
296
|
+
|
|
297
|
+
prepareSources(comp);
|
|
298
|
+
|
|
299
|
+
let newValue: unknown;
|
|
300
|
+
let fnThrew = false;
|
|
301
|
+
try {
|
|
302
|
+
newValue = comp._fn();
|
|
303
|
+
} catch (err: unknown) {
|
|
304
|
+
// PATH A: fn() threw — cache the error
|
|
305
|
+
fnThrew = true;
|
|
306
|
+
comp._value = err;
|
|
307
|
+
comp._flags = HAS_ERROR | TRACKING;
|
|
308
|
+
comp._version++;
|
|
309
|
+
// Keep old deps (version=-1 nodes) so computed retries when sources change.
|
|
310
|
+
let sn = comp._sources;
|
|
311
|
+
while (sn !== undefined) {
|
|
312
|
+
if (sn._version === -1) {
|
|
313
|
+
sn._version = 0;
|
|
314
|
+
}
|
|
315
|
+
sn = sn._nextSource;
|
|
316
|
+
}
|
|
317
|
+
} finally {
|
|
318
|
+
evalContext = prevContext;
|
|
319
|
+
cleanupSources(comp);
|
|
320
|
+
if (!fnThrew) {
|
|
321
|
+
comp._flags = (comp._flags & ~RUNNING) | TRACKING;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// PATH B: fn() succeeded — compare with equals() (F2: separate try/catch)
|
|
326
|
+
if (!fnThrew) {
|
|
327
|
+
const oldHadError = prevFlags & HAS_ERROR;
|
|
328
|
+
let changed: boolean;
|
|
329
|
+
|
|
330
|
+
if (oldHadError || comp._version === 0) {
|
|
331
|
+
changed = true;
|
|
332
|
+
} else {
|
|
333
|
+
try {
|
|
334
|
+
const eq = comp._equals;
|
|
335
|
+
changed = eq ? !eq(comp._value, newValue) : !Object.is(comp._value, newValue);
|
|
336
|
+
} catch {
|
|
337
|
+
// equals() threw — treat as changed (F2: successful value NOT poisoned)
|
|
338
|
+
changed = true;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (changed) {
|
|
343
|
+
comp._value = newValue;
|
|
344
|
+
comp._flags &= ~HAS_ERROR;
|
|
345
|
+
comp._version++;
|
|
346
|
+
} else {
|
|
347
|
+
comp._flags &= ~HAS_ERROR;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- Batch / Effect scheduling ---
|
|
353
|
+
function startBatch(): void {
|
|
354
|
+
batchDepth++;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function endBatch(): void {
|
|
358
|
+
if (--batchDepth > 0) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
let error: unknown;
|
|
362
|
+
let hasError = false;
|
|
363
|
+
while (batchedEffect !== undefined) {
|
|
364
|
+
let eff: EffectNode | undefined = batchedEffect;
|
|
365
|
+
batchedEffect = undefined;
|
|
366
|
+
batchIteration++;
|
|
367
|
+
if (batchIteration > 100) {
|
|
368
|
+
batchIteration = 0;
|
|
369
|
+
throw new Error("Cycle detected");
|
|
370
|
+
}
|
|
371
|
+
while (eff !== undefined) {
|
|
372
|
+
const next: EffectNode | undefined = eff._nextBatchedEffect;
|
|
373
|
+
eff._nextBatchedEffect = undefined;
|
|
374
|
+
eff._flags &= ~NOTIFIED;
|
|
375
|
+
if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
|
|
376
|
+
try {
|
|
377
|
+
runEffect(eff);
|
|
378
|
+
} catch (e) {
|
|
379
|
+
if (!hasError) {
|
|
380
|
+
error = e;
|
|
381
|
+
hasError = true;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
eff = next;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
batchIteration = 0;
|
|
389
|
+
if (hasError) {
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function runEffect(eff: EffectNode): void {
|
|
395
|
+
// Run cleanup first (untracked)
|
|
396
|
+
if (eff._cleanup) {
|
|
397
|
+
const c = eff._cleanup;
|
|
398
|
+
eff._cleanup = undefined;
|
|
399
|
+
const prevContext = evalContext;
|
|
400
|
+
evalContext = undefined;
|
|
401
|
+
try {
|
|
402
|
+
c();
|
|
403
|
+
} catch (e) {
|
|
404
|
+
safeCallHandler(e);
|
|
405
|
+
} finally {
|
|
406
|
+
evalContext = prevContext;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Track dependencies
|
|
411
|
+
const prevContext = evalContext;
|
|
412
|
+
evalContext = eff;
|
|
413
|
+
prepareSources(eff);
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
if (eff._fn) {
|
|
417
|
+
eff._cleanup = eff._fn();
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
safeCallHandler(e);
|
|
421
|
+
} finally {
|
|
422
|
+
evalContext = prevContext;
|
|
423
|
+
cleanupSources(eff);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function disposeEffect(eff: EffectNode): void {
|
|
428
|
+
// Run cleanup (untracked)
|
|
429
|
+
if (eff._cleanup) {
|
|
430
|
+
const c = eff._cleanup;
|
|
431
|
+
eff._cleanup = undefined;
|
|
432
|
+
const prevContext = evalContext;
|
|
433
|
+
evalContext = undefined;
|
|
434
|
+
try {
|
|
435
|
+
c();
|
|
436
|
+
} catch (e) {
|
|
437
|
+
safeCallHandler(e);
|
|
438
|
+
} finally {
|
|
439
|
+
evalContext = prevContext;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Unsubscribe from all sources
|
|
444
|
+
let node = eff._sources;
|
|
445
|
+
while (node !== undefined) {
|
|
446
|
+
const next = node._nextSource;
|
|
447
|
+
if (node._prevTarget !== undefined) {
|
|
448
|
+
node._prevTarget._nextTarget = node._nextTarget;
|
|
449
|
+
} else {
|
|
450
|
+
node._source._targets = node._nextTarget;
|
|
451
|
+
}
|
|
452
|
+
if (node._nextTarget !== undefined) {
|
|
453
|
+
node._nextTarget._prevTarget = node._prevTarget;
|
|
454
|
+
}
|
|
455
|
+
node._source._node = node._rollbackNode;
|
|
456
|
+
node = next;
|
|
457
|
+
}
|
|
458
|
+
eff._sources = undefined;
|
|
459
|
+
// F5: null closures for GC
|
|
460
|
+
eff._fn = undefined;
|
|
461
|
+
eff._cleanup = undefined;
|
|
462
|
+
eff._flags |= DISPOSED;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- Public API ---
|
|
466
|
+
|
|
467
|
+
/** Options for configuring signal equality semantics. */
|
|
468
|
+
export interface SignalOptions<T> {
|
|
469
|
+
equals?: false | ((prev: T, next: T) => boolean);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A reactive signal holding a mutable value that notifies subscribers on change. */
|
|
473
|
+
export interface Signal<T> {
|
|
474
|
+
value: T;
|
|
475
|
+
peek(): T;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** A read-only reactive signal (e.g. from computed). */
|
|
479
|
+
export interface ReadonlySignal<T> {
|
|
480
|
+
readonly value: T;
|
|
481
|
+
peek(): T;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Create a reactive signal with an initial value. Reads are tracked; writes notify subscribers. */
|
|
485
|
+
export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
|
|
486
|
+
const eq = options?.equals;
|
|
487
|
+
const node: SourceNode = {
|
|
488
|
+
_value: initial,
|
|
489
|
+
_version: 0,
|
|
490
|
+
_node: undefined,
|
|
491
|
+
_targets: undefined,
|
|
492
|
+
_isComputed: false,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const s = {
|
|
496
|
+
[SIGNAL_BRAND]: true,
|
|
497
|
+
get value(): T {
|
|
498
|
+
const dep = addDependency(node);
|
|
499
|
+
if (dep !== undefined) {
|
|
500
|
+
dep._version = node._version;
|
|
501
|
+
}
|
|
502
|
+
return node._value as T;
|
|
503
|
+
},
|
|
504
|
+
set value(v: T) {
|
|
505
|
+
if (eq === false ? false : eq ? eq(node._value as T, v) : Object.is(node._value, v)) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
node._value = v;
|
|
509
|
+
node._version++;
|
|
510
|
+
globalVersion++;
|
|
511
|
+
startBatch();
|
|
512
|
+
try {
|
|
513
|
+
notifyTargets(node);
|
|
514
|
+
} finally {
|
|
515
|
+
endBatch();
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
peek(): T {
|
|
519
|
+
return node._value as T;
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
return s;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Lazy cached derived signal with equality dedup (glitch-free). */
|
|
526
|
+
export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySignal<T> {
|
|
527
|
+
const eq = options?.equals;
|
|
528
|
+
const node: ComputedNode = {
|
|
529
|
+
// SourceNode fields
|
|
530
|
+
_value: undefined,
|
|
531
|
+
_version: 0,
|
|
532
|
+
_node: undefined,
|
|
533
|
+
_targets: undefined,
|
|
534
|
+
_isComputed: true,
|
|
535
|
+
// TargetNode fields
|
|
536
|
+
_flags: DIRTY | TRACKING,
|
|
537
|
+
_sources: undefined,
|
|
538
|
+
// ComputedNode-specific fields
|
|
539
|
+
_fn: fn,
|
|
540
|
+
_equals:
|
|
541
|
+
eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
|
|
542
|
+
_globalVersion: -1,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const c = {
|
|
546
|
+
[COMPUTED_BRAND]: true,
|
|
547
|
+
get value(): T {
|
|
548
|
+
// Cycle detection
|
|
549
|
+
if (node._flags & RUNNING) {
|
|
550
|
+
throw new Error("Cycle detected");
|
|
551
|
+
}
|
|
552
|
+
// Refresh before tracking (pull-based glitch-free)
|
|
553
|
+
if (node._flags & (DIRTY | CHECK)) {
|
|
554
|
+
refreshComputed(node);
|
|
555
|
+
}
|
|
556
|
+
const dep = addDependency(node);
|
|
557
|
+
if (dep !== undefined) {
|
|
558
|
+
dep._version = node._version;
|
|
559
|
+
}
|
|
560
|
+
if (node._flags & HAS_ERROR) {
|
|
561
|
+
throw node._value;
|
|
562
|
+
}
|
|
563
|
+
return node._value as T;
|
|
564
|
+
},
|
|
565
|
+
set value(_v: T) {
|
|
566
|
+
throw new Error("Cannot set a computed signal");
|
|
567
|
+
},
|
|
568
|
+
peek(): T {
|
|
569
|
+
// F6: refresh WITHOUT tracking
|
|
570
|
+
if (node._flags & (DIRTY | CHECK)) {
|
|
571
|
+
const prevContext = evalContext;
|
|
572
|
+
evalContext = undefined;
|
|
573
|
+
try {
|
|
574
|
+
refreshComputed(node);
|
|
575
|
+
} finally {
|
|
576
|
+
evalContext = prevContext;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (node._flags & HAS_ERROR) {
|
|
580
|
+
throw node._value;
|
|
581
|
+
}
|
|
582
|
+
return node._value as T;
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
return c;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Create a reactive effect that re-runs when its tracked signals change. Returns a dispose function. */
|
|
589
|
+
export function effect(fn: () => Cleanup): () => void {
|
|
590
|
+
const eff: EffectNode = {
|
|
591
|
+
_fn: fn,
|
|
592
|
+
_cleanup: undefined,
|
|
593
|
+
_flags: 0,
|
|
594
|
+
_sources: undefined,
|
|
595
|
+
_nextBatchedEffect: undefined,
|
|
596
|
+
_isComputed: false,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Run immediately
|
|
600
|
+
try {
|
|
601
|
+
runEffect(eff);
|
|
602
|
+
} catch (e) {
|
|
603
|
+
safeCallHandler(e);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return () => {
|
|
607
|
+
if (eff._flags & DISPOSED) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
disposeEffect(eff);
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Coalesce signal writes; effects flush synchronously at end of outermost batch. */
|
|
615
|
+
export function batch(fn: () => void): void {
|
|
616
|
+
startBatch();
|
|
617
|
+
try {
|
|
618
|
+
fn();
|
|
619
|
+
} finally {
|
|
620
|
+
endBatch();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Flush all pending effects synchronously. No-op inside batch(). */
|
|
625
|
+
export function flushSync(): void {
|
|
626
|
+
if (batchDepth > 0) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
startBatch();
|
|
630
|
+
endBatch();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Run fn without tracking any signal reads. */
|
|
634
|
+
export function untracked<T>(fn: () => T): T {
|
|
635
|
+
const prev = evalContext;
|
|
636
|
+
evalContext = undefined;
|
|
637
|
+
try {
|
|
638
|
+
return fn();
|
|
639
|
+
} finally {
|
|
640
|
+
evalContext = prev;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Subscribe to a signal, calling cb on every change. Returns dispose function. */
|
|
645
|
+
export function subscribe<T>(
|
|
646
|
+
sig: Signal<T> | ReadonlySignal<T>,
|
|
647
|
+
cb: (value: T) => void,
|
|
648
|
+
): () => void {
|
|
649
|
+
return effect(() => {
|
|
650
|
+
cb(sig.value);
|
|
651
|
+
return undefined;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Type guard: returns true if value is a Signal created by signal(). */
|
|
656
|
+
export function isSignal(value: unknown): value is Signal<unknown> {
|
|
657
|
+
return value !== null && typeof value === "object" && SIGNAL_BRAND in value;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Type guard: returns true if value is a computed ReadonlySignal. */
|
|
661
|
+
export function isComputed(value: unknown): value is ReadonlySignal<unknown> {
|
|
662
|
+
return value !== null && typeof value === "object" && COMPUTED_BRAND in value;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Explicit dependency declaration helper. Mirrors Solid's on(). */
|
|
666
|
+
export function on<T, U>(
|
|
667
|
+
deps: (() => T) | (() => unknown)[],
|
|
668
|
+
fn: (value: T | unknown[], prev: T | unknown[] | undefined, prevResult: U | undefined) => U,
|
|
669
|
+
options?: { defer?: boolean },
|
|
670
|
+
): () => U | undefined {
|
|
671
|
+
let prevInput: T | unknown[] | undefined;
|
|
672
|
+
let prevResult: U | undefined;
|
|
673
|
+
let first = true;
|
|
674
|
+
return () => {
|
|
675
|
+
const input: T | unknown[] = Array.isArray(deps) ? deps.map((d) => d()) : deps();
|
|
676
|
+
if (first && options?.defer) {
|
|
677
|
+
first = false;
|
|
678
|
+
prevInput = input;
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
const result = untracked(() => fn(input, prevInput, prevResult));
|
|
682
|
+
prevInput = input;
|
|
683
|
+
prevResult = result;
|
|
684
|
+
first = false;
|
|
685
|
+
return result;
|
|
686
|
+
};
|
|
687
|
+
}
|