flint-reactivity 4.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.
@@ -0,0 +1,757 @@
1
+ // Flint Reactivity — Core signals implementation v4
2
+ // Version-based dirty tracking with automatic dependency tracking
3
+ import { SIGNAL_BRAND, COMPUTED_BRAND, EFFECT_BRAND, } from './types.js';
4
+ // ─── Global State ───────────────────────────────────────────────
5
+ let currentSubscriber = null;
6
+ let currentScope = null;
7
+ let writeVersion = 0;
8
+ let readVersion = 0;
9
+ let batchDepth = 0;
10
+ let pendingEffects = new Set();
11
+ let pendingComputeds = new Set();
12
+ let flushScheduled = false;
13
+ // ─── Equality ───────────────────────────────────────────────────
14
+ function defaultEquals(a, b) {
15
+ return Object.is(a, b);
16
+ }
17
+ // ─── Dependency Tracking ────────────────────────────────────────
18
+ function track(signal) {
19
+ if (currentSubscriber) {
20
+ signal.observers.add(currentSubscriber);
21
+ currentSubscriber.dependencies.add(signal);
22
+ }
23
+ }
24
+ function untrackAll(subscriber) {
25
+ for (const dep of subscriber.dependencies) {
26
+ dep.observers.delete(subscriber);
27
+ }
28
+ subscriber.dependencies.clear();
29
+ }
30
+ // ─── Dirty Marking ──────────────────────────────────────────────
31
+ const updatingComputeds = new WeakSet();
32
+ function markDirty(signal) {
33
+ signal.version = writeVersion;
34
+ for (const observer of signal.observers) {
35
+ if (observer.kind === 'computed') {
36
+ if (!observer.dirty) {
37
+ if (updatingComputeds.has(observer)) {
38
+ console.warn('[Flint] Circular computed dependency detected:', observer);
39
+ continue;
40
+ }
41
+ observer.dirty = true;
42
+ markDirty(observer);
43
+ }
44
+ }
45
+ else if (observer.kind === 'effect') {
46
+ scheduleEffect(observer);
47
+ }
48
+ }
49
+ }
50
+ function isDirty(computed) {
51
+ for (const dep of computed.dependencies) {
52
+ if (dep.version > computed.version) {
53
+ return true;
54
+ }
55
+ }
56
+ return false;
57
+ }
58
+ function refreshComputedDeps(deps) {
59
+ for (const dep of deps) {
60
+ if (dep.kind === 'computed' && !dep.disposed) {
61
+ refreshComputedDeps(dep.dependencies);
62
+ if (dep.dirty || isDirty(dep)) {
63
+ updateComputed(dep);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ function isEffectStale(effectState) {
69
+ refreshComputedDeps(effectState.dependencies);
70
+ for (const dep of effectState.dependencies) {
71
+ if (dep.changeVersion > effectState.version) {
72
+ return true;
73
+ }
74
+ }
75
+ return false;
76
+ }
77
+ // ─── Effect Scheduling ──────────────────────────────────────────
78
+ function scheduleEffect(effect) {
79
+ if (!effect.disposed) {
80
+ pendingEffects.add(effect);
81
+ }
82
+ scheduleFlush();
83
+ }
84
+ function scheduleFlush() {
85
+ if (!flushScheduled && batchDepth === 0) {
86
+ flushScheduled = true;
87
+ queueMicrotask(flush);
88
+ }
89
+ }
90
+ function flush() {
91
+ flushScheduled = false;
92
+ while (pendingComputeds.size > 0) {
93
+ const computeds = [...pendingComputeds];
94
+ pendingComputeds.clear();
95
+ for (const computed of computeds) {
96
+ if (computed.dirty && !computed.disposed) {
97
+ updateComputed(computed);
98
+ }
99
+ }
100
+ }
101
+ const effects = [...pendingEffects];
102
+ pendingEffects.clear();
103
+ for (const effectState of effects) {
104
+ if (!effectState.disposed && isEffectStale(effectState)) {
105
+ runEffect(effectState);
106
+ }
107
+ }
108
+ }
109
+ // ─── Core Primitives ────────────────────────────────────────────
110
+ function updateComputed(computed) {
111
+ updateComputedWithEquals(computed, computed.equals ?? defaultEquals);
112
+ }
113
+ function updateComputedWithEquals(computed, comparator) {
114
+ if (updatingComputeds.has(computed)) {
115
+ console.warn('[Flint] Circular computed dependency detected during update:', computed);
116
+ return;
117
+ }
118
+ updatingComputeds.add(computed);
119
+ untrackAll(computed);
120
+ const prevSubscriber = currentSubscriber;
121
+ currentSubscriber = computed;
122
+ computed.tracking = true;
123
+ readVersion++;
124
+ try {
125
+ const prevValue = computed.value;
126
+ const newValue = computed.fn();
127
+ const unchanged = comparator(prevValue, newValue);
128
+ if (!unchanged) {
129
+ computed.value = newValue;
130
+ computed.version = writeVersion;
131
+ computed.changeVersion = writeVersion;
132
+ for (const observer of computed.observers) {
133
+ if (observer.kind === 'computed') {
134
+ if (!observer.dirty) {
135
+ observer.dirty = true;
136
+ pendingComputeds.add(observer);
137
+ }
138
+ }
139
+ else if (observer.kind === 'effect') {
140
+ scheduleEffect(observer);
141
+ }
142
+ }
143
+ }
144
+ computed.dirty = false;
145
+ }
146
+ finally {
147
+ computed.tracking = false;
148
+ currentSubscriber = prevSubscriber;
149
+ updatingComputeds.delete(computed);
150
+ }
151
+ }
152
+ function runEffect(effect) {
153
+ if (effect.disposed)
154
+ return;
155
+ if (effect.cleanup) {
156
+ effect.cleanup();
157
+ effect.cleanup = null;
158
+ }
159
+ untrackAll(effect);
160
+ const prevSubscriber = currentSubscriber;
161
+ currentSubscriber = effect;
162
+ effect.tracking = true;
163
+ readVersion++;
164
+ try {
165
+ const result = effect.fn();
166
+ if (typeof result === 'function') {
167
+ effect.cleanup = result;
168
+ }
169
+ effect.version = writeVersion;
170
+ }
171
+ finally {
172
+ effect.tracking = false;
173
+ currentSubscriber = prevSubscriber;
174
+ }
175
+ }
176
+ // ─── Public API ─────────────────────────────────────────────────
177
+ /**
178
+ * Create a reactive state signal.
179
+ *
180
+ * @example
181
+ * const count = state(0)
182
+ * count() // read: 0
183
+ * count.set(5) // write: 5
184
+ * count.set(c => c + 1) // write: 6
185
+ */
186
+ export function state(initial) {
187
+ const signalState = {
188
+ kind: 'signal',
189
+ value: initial,
190
+ version: 0,
191
+ changeVersion: 0,
192
+ observers: new Set(),
193
+ comparator: defaultEquals,
194
+ };
195
+ const read = (() => {
196
+ track(signalState);
197
+ return signalState.value;
198
+ });
199
+ Object.defineProperty(read, SIGNAL_BRAND, { value: true });
200
+ read.set = (value) => {
201
+ const newValue = typeof value === 'function'
202
+ ? value(signalState.value)
203
+ : value;
204
+ if (!signalState.comparator(signalState.value, newValue)) {
205
+ signalState.value = newValue;
206
+ writeVersion++;
207
+ signalState.changeVersion = writeVersion;
208
+ markDirty(signalState);
209
+ scheduleFlush();
210
+ }
211
+ };
212
+ read.peek = () => signalState.value;
213
+ read.subscribe = (fn) => {
214
+ const eff = effect(() => fn(signalState.value));
215
+ return () => eff.dispose();
216
+ };
217
+ read.map = (fn) => {
218
+ return computed(() => fn(signalState.value));
219
+ };
220
+ read.pipe = (fn1) => {
221
+ return computed(() => fn1(signalState.value));
222
+ };
223
+ return read;
224
+ }
225
+ /**
226
+ * Create a computed (derived) value.
227
+ * Lazily evaluated and cached. Only recomputes when dependencies change.
228
+ */
229
+ export function computed(fn, options) {
230
+ const comparator = options?.equals ?? defaultEquals;
231
+ const computedState = {
232
+ kind: 'computed',
233
+ value: undefined,
234
+ version: 0,
235
+ changeVersion: 0,
236
+ dirty: true,
237
+ disposed: false,
238
+ fn,
239
+ observers: new Set(),
240
+ dependencies: new Set(),
241
+ tracking: false,
242
+ };
243
+ const read = (() => {
244
+ track(computedState);
245
+ if (computedState.dirty || isDirty(computedState)) {
246
+ updateComputedWithEquals(computedState, comparator);
247
+ }
248
+ return computedState.value;
249
+ });
250
+ Object.defineProperty(read, COMPUTED_BRAND, { value: true });
251
+ computedState.equals = comparator;
252
+ updateComputedWithEquals(computedState, comparator);
253
+ return read;
254
+ }
255
+ /**
256
+ * Create a writable computed value.
257
+ * Like computed, but can be set directly.
258
+ *
259
+ * @example
260
+ * const count = state(0)
261
+ * const doubled = computedSet({
262
+ * get: () => count() * 2,
263
+ * set: (value) => count.set(value / 2)
264
+ * })
265
+ *
266
+ * doubled() // read: 0
267
+ * doubled.set(10) // write: sets count to 5
268
+ */
269
+ export function computedSet(options) {
270
+ const comparator = options.equals ?? defaultEquals;
271
+ const computedState = {
272
+ kind: 'computed',
273
+ value: undefined,
274
+ version: 0,
275
+ changeVersion: 0,
276
+ dirty: true,
277
+ disposed: false,
278
+ fn: options.get,
279
+ observers: new Set(),
280
+ dependencies: new Set(),
281
+ tracking: false,
282
+ };
283
+ const read = (() => {
284
+ track(computedState);
285
+ if (computedState.dirty || isDirty(computedState)) {
286
+ updateComputedWithEquals(computedState, comparator);
287
+ }
288
+ return computedState.value;
289
+ });
290
+ Object.defineProperty(read, COMPUTED_BRAND, { value: true });
291
+ computedState.equals = comparator;
292
+ updateComputedWithEquals(computedState, comparator);
293
+ // Add set method
294
+ read.set = (value) => {
295
+ const newValue = typeof value === 'function'
296
+ ? value(computedState.value)
297
+ : value;
298
+ options.set(newValue);
299
+ };
300
+ read.peek = () => computedState.value;
301
+ return read;
302
+ }
303
+ /**
304
+ * Create a side effect that auto-tracks dependencies.
305
+ */
306
+ export function effect(fn) {
307
+ const effectState = {
308
+ kind: 'effect',
309
+ fn,
310
+ cleanup: null,
311
+ dependencies: new Set(),
312
+ tracking: false,
313
+ disposed: false,
314
+ version: 0,
315
+ };
316
+ runEffect(effectState);
317
+ return {
318
+ [EFFECT_BRAND]: true,
319
+ dispose() {
320
+ effectState.disposed = true;
321
+ if (effectState.cleanup) {
322
+ effectState.cleanup();
323
+ effectState.cleanup = null;
324
+ }
325
+ untrackAll(effectState);
326
+ },
327
+ };
328
+ }
329
+ /**
330
+ * Watch a source function and call callback when value changes.
331
+ */
332
+ export function watch(source, callback) {
333
+ let lastValue = undefined;
334
+ let initialized = false;
335
+ const eff = effect(() => {
336
+ const value = source();
337
+ if (initialized) {
338
+ callback(value, lastValue);
339
+ }
340
+ lastValue = value;
341
+ initialized = true;
342
+ });
343
+ return {
344
+ dispose() {
345
+ eff.dispose();
346
+ },
347
+ };
348
+ }
349
+ /**
350
+ * Batch multiple signal updates into a single flush.
351
+ */
352
+ export function batch(fn) {
353
+ batchDepth++;
354
+ try {
355
+ fn();
356
+ }
357
+ finally {
358
+ batchDepth--;
359
+ if (batchDepth === 0) {
360
+ scheduleFlush();
361
+ }
362
+ }
363
+ }
364
+ /**
365
+ * Run queued effects/computeds immediately.
366
+ */
367
+ export function flushSync() {
368
+ flush();
369
+ }
370
+ // ─── captureScope ─────────────────────────────────────────────
371
+ export function captureScope(fn) {
372
+ const probe = {
373
+ kind: 'effect',
374
+ fn: () => undefined,
375
+ cleanup: null,
376
+ dependencies: new Set(),
377
+ tracking: true,
378
+ disposed: true,
379
+ version: 0,
380
+ };
381
+ const prevSubscriber = currentSubscriber;
382
+ currentSubscriber = probe;
383
+ let value;
384
+ try {
385
+ value = fn();
386
+ }
387
+ finally {
388
+ currentSubscriber = prevSubscriber;
389
+ for (const dep of probe.dependencies) {
390
+ dep.observers.delete(probe);
391
+ }
392
+ }
393
+ return { value, dependencies: probe.dependencies };
394
+ }
395
+ // ─── untrack ────────────────────────────────────────────────────
396
+ export function untrack(fn) {
397
+ const prevSubscriber = currentSubscriber;
398
+ currentSubscriber = null;
399
+ try {
400
+ return fn();
401
+ }
402
+ finally {
403
+ currentSubscriber = prevSubscriber;
404
+ }
405
+ }
406
+ // ─── createRoot / Scope ─────────────────────────────────────────
407
+ export function createRoot(fn) {
408
+ const scopeState = {
409
+ kind: 'scope',
410
+ disposables: [],
411
+ disposed: false,
412
+ parent: currentScope,
413
+ };
414
+ const prevScope = currentScope;
415
+ currentScope = scopeState;
416
+ function dispose() {
417
+ if (scopeState.disposed)
418
+ return;
419
+ scopeState.disposed = true;
420
+ for (let i = scopeState.disposables.length - 1; i >= 0; i--) {
421
+ try {
422
+ scopeState.disposables[i]();
423
+ }
424
+ catch (e) {
425
+ console.warn('[Flint] Disposable cleanup failed:', e);
426
+ }
427
+ }
428
+ scopeState.disposables.length = 0;
429
+ currentScope = prevScope;
430
+ }
431
+ try {
432
+ const result = fn(dispose);
433
+ const scope = {
434
+ dispose,
435
+ onCleanup(fn) {
436
+ if (!scopeState.disposed) {
437
+ scopeState.disposables.push(fn);
438
+ }
439
+ },
440
+ get disposed() {
441
+ return scopeState.disposed;
442
+ },
443
+ };
444
+ return Object.assign(result, scope);
445
+ }
446
+ finally {
447
+ currentScope = prevScope;
448
+ }
449
+ }
450
+ export function onCleanup(fn) {
451
+ if (currentScope) {
452
+ currentScope.disposables.push(fn);
453
+ }
454
+ if (currentSubscriber && currentSubscriber.kind === 'effect') {
455
+ const effectState = currentSubscriber;
456
+ const prevCleanup = effectState.cleanup;
457
+ effectState.cleanup = () => {
458
+ if (prevCleanup)
459
+ prevCleanup();
460
+ fn();
461
+ };
462
+ }
463
+ }
464
+ // ─── createSelector ─────────────────────────────────────────────
465
+ export function createSelector(source) {
466
+ const sourceFn = typeof source === 'function' ? source : () => source();
467
+ let currentValue;
468
+ const effectsByValue = new Map();
469
+ const eff = effect(() => {
470
+ currentValue = sourceFn();
471
+ });
472
+ function select(key) {
473
+ if (currentSubscriber) {
474
+ if (!effectsByValue.has(key)) {
475
+ effectsByValue.set(key, new Set());
476
+ }
477
+ effectsByValue.get(key).add(currentSubscriber);
478
+ }
479
+ return key === currentValue;
480
+ }
481
+ return Object.assign(select, {
482
+ setSelected(keyOrSet) {
483
+ if (keyOrSet instanceof Set) {
484
+ batch(() => {
485
+ for (const key of keyOrSet) {
486
+ const subs = effectsByValue.get(key);
487
+ if (subs) {
488
+ for (const sub of subs) {
489
+ scheduleEffect(sub);
490
+ }
491
+ }
492
+ }
493
+ });
494
+ }
495
+ else {
496
+ if (typeof source === 'function' && 'set' in source) {
497
+ source.set(keyOrSet);
498
+ }
499
+ else if (typeof source !== 'function' && 'set' in source) {
500
+ source.set(keyOrSet);
501
+ }
502
+ }
503
+ },
504
+ getSelected() {
505
+ return new Set([currentValue]);
506
+ },
507
+ isSelected(key) {
508
+ return key === currentValue;
509
+ },
510
+ dispose() {
511
+ eff.dispose();
512
+ effectsByValue.clear();
513
+ },
514
+ });
515
+ }
516
+ export { onCleanup as onDispose };
517
+ // ─── NEW: Simplified APIs for Maximum DX ───────────────────────
518
+ /**
519
+ * Create a reactive object with Proxy. Reads auto-track, writes auto-trigger.
520
+ *
521
+ * @example
522
+ * const user = reactive({ name: 'John', age: 30 })
523
+ * user.name // tracked
524
+ * user.age = 31 // triggers effects
525
+ */
526
+ export function reactive(obj) {
527
+ const signalMap = new Map();
528
+ for (const key of Object.keys(obj)) {
529
+ signalMap.set(key, state(obj[key]));
530
+ }
531
+ return new Proxy(obj, {
532
+ get(target, key) {
533
+ const sig = signalMap.get(key);
534
+ if (sig)
535
+ return sig();
536
+ return target[key];
537
+ },
538
+ set(target, key, value) {
539
+ const sig = signalMap.get(key);
540
+ if (sig) {
541
+ sig.set(value);
542
+ }
543
+ else {
544
+ signalMap.set(key, state(value));
545
+ target[key] = value;
546
+ }
547
+ return true;
548
+ },
549
+ has(target, key) {
550
+ return key in target;
551
+ },
552
+ ownKeys(target) {
553
+ return Reflect.ownKeys(target);
554
+ },
555
+ getOwnPropertyDescriptor(target, key) {
556
+ return Reflect.getOwnPropertyDescriptor(target, key);
557
+ },
558
+ });
559
+ }
560
+ /**
561
+ * Create a model — a reactive container with actions.
562
+ * Combines state + computed + actions in one clean API.
563
+ *
564
+ * @example
565
+ * const counter = model({
566
+ * state: { count: 0, step: 1 },
567
+ * computed: {
568
+ * doubled: (s) => s.count * 2,
569
+ * isPositive: (s) => s.count > 0,
570
+ * },
571
+ * actions: {
572
+ * increment(s) { s.count += s.step },
573
+ * decrement(s) { s.count -= s.step },
574
+ * reset(s) { s.count = 0 },
575
+ * },
576
+ * })
577
+ *
578
+ * counter.count() // 0
579
+ * counter.doubled() // 0
580
+ * counter.increment() // s.count = 1
581
+ */
582
+ export function model(config) {
583
+ const signals = {};
584
+ const result = {};
585
+ // Create signals for state
586
+ for (const [key, value] of Object.entries(config.state)) {
587
+ signals[key] = state(value);
588
+ result[key] = signals[key];
589
+ }
590
+ // Create computeds
591
+ if (config.computed) {
592
+ for (const [key, fn] of Object.entries(config.computed)) {
593
+ result[key] = computed(() => {
594
+ const currentState = {};
595
+ for (const [k, sig] of Object.entries(signals)) {
596
+ ;
597
+ currentState[k] = sig();
598
+ }
599
+ return fn(currentState);
600
+ });
601
+ }
602
+ }
603
+ // Create actions
604
+ if (config.actions) {
605
+ for (const [key, fn] of Object.entries(config.actions)) {
606
+ result[key] = (...args) => {
607
+ const mutableState = {};
608
+ for (const [k, sig] of Object.entries(signals)) {
609
+ ;
610
+ mutableState[k] = sig.peek();
611
+ }
612
+ ;
613
+ fn(mutableState, ...args);
614
+ for (const [k, sig] of Object.entries(signals)) {
615
+ if (mutableState[k] !== sig.peek()) {
616
+ sig.set(mutableState[k]);
617
+ }
618
+ }
619
+ };
620
+ }
621
+ }
622
+ return result;
623
+ }
624
+ /**
625
+ * Create a two-way binding between a signal and a DOM element property.
626
+ * Simplifies form handling dramatically.
627
+ *
628
+ * @example
629
+ * const name = state('')
630
+ * <input {...bind(name, 'value')} onInput={(e) => name.set(e.target.value)} />
631
+ */
632
+ export function bind(signal, prop) {
633
+ return {
634
+ [prop]: () => signal(),
635
+ };
636
+ }
637
+ /**
638
+ * Create a mutable ref (like React's useRef).
639
+ */
640
+ export function createRef(initial) {
641
+ return { current: initial };
642
+ }
643
+ /**
644
+ * Create a shallow ref — only triggers on reference change, not deep mutation.
645
+ */
646
+ export function shallowRef(value) {
647
+ const sig = state(value);
648
+ const ref = Object.assign((() => sig()), {
649
+ get current() { return sig(); },
650
+ set current(v) { sig.set(v); },
651
+ set: sig.set.bind(sig),
652
+ peek: sig.peek.bind(sig),
653
+ });
654
+ return ref;
655
+ }
656
+ /**
657
+ * Group multiple signals into a single derived signal.
658
+ *
659
+ * @example
660
+ * const [doubled, tripled] = derive(
661
+ * [count],
662
+ * (c) => [c * 2, c * 3]
663
+ * )
664
+ */
665
+ export function derive(sources, fn) {
666
+ return computed(() => {
667
+ const values = sources.map(s => s());
668
+ return fn(...values);
669
+ });
670
+ }
671
+ /**
672
+ * Batch create multiple signals from an object.
673
+ *
674
+ * @example
675
+ * const [count, name, items] = signals(0, 'hello', [])
676
+ */
677
+ export function signals(...initials) {
678
+ return initials.map(init => state(init));
679
+ }
680
+ /**
681
+ * Create an effect that runs on a specific interval.
682
+ *
683
+ * @example
684
+ * poll(() => fetchData(), 5000) // poll every 5 seconds
685
+ */
686
+ export function poll(fn, ms) {
687
+ const id = setInterval(() => fn(), ms);
688
+ return {
689
+ [EFFECT_BRAND]: true,
690
+ dispose() {
691
+ clearInterval(id);
692
+ },
693
+ };
694
+ }
695
+ /**
696
+ * Create an effect that runs when a signal changes (debounced).
697
+ *
698
+ * @example
699
+ * watchDebounced(searchQuery, (q) => fetchResults(q), 300)
700
+ */
701
+ export function watchDebounced(source, callback, ms) {
702
+ let timeoutId;
703
+ let lastValue;
704
+ let initialized = false;
705
+ const eff = effect(() => {
706
+ const value = source();
707
+ if (initialized) {
708
+ clearTimeout(timeoutId);
709
+ timeoutId = setTimeout(() => callback(value, lastValue), ms);
710
+ }
711
+ lastValue = value;
712
+ initialized = true;
713
+ });
714
+ return {
715
+ dispose() {
716
+ clearTimeout(timeoutId);
717
+ eff.dispose();
718
+ },
719
+ };
720
+ }
721
+ /**
722
+ * Create an effect that runs when a signal changes (throttled).
723
+ */
724
+ export function watchThrottled(source, callback, ms) {
725
+ let lastRun = 0;
726
+ let lastValue;
727
+ let initialized = false;
728
+ let pendingValue = false;
729
+ const eff = effect(() => {
730
+ const value = source();
731
+ if (initialized) {
732
+ const now = Date.now();
733
+ if (now - lastRun >= ms) {
734
+ lastRun = now;
735
+ callback(value, lastValue);
736
+ }
737
+ else {
738
+ pendingValue = value;
739
+ setTimeout(() => {
740
+ if (pendingValue !== undefined) {
741
+ callback(pendingValue, lastValue);
742
+ pendingValue = undefined;
743
+ lastRun = Date.now();
744
+ }
745
+ }, ms - (now - lastRun));
746
+ }
747
+ }
748
+ lastValue = value;
749
+ initialized = true;
750
+ });
751
+ return {
752
+ dispose() {
753
+ eff.dispose();
754
+ },
755
+ };
756
+ }
757
+ //# sourceMappingURL=signals.js.map