@rlabs-inc/signals 1.8.2 → 1.10.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/README.md CHANGED
@@ -2,16 +2,29 @@
2
2
 
3
3
  **Production-grade fine-grained reactivity for TypeScript.**
4
4
 
5
- A complete standalone mirror of Svelte 5's reactivity system, enhanced with Angular's linkedSignal, Solid's createSelector, and Vue's effectScope. No compiler needed, no DOM, works anywhere - Bun, Node, Deno, or browser.
5
+ A complete standalone implementation of modern reactivity patterns, combining the best of Svelte 5, Angular, Solid, and Vue. No compiler needed, no DOM dependencies - works anywhere: Bun, Node, Deno, or browser.
6
6
 
7
- ## Why This Library?
7
+ ## Features
8
8
 
9
- - **True Fine-Grained Reactivity** - Changes to deeply nested properties only trigger effects that read that exact path
10
- - **Per-Property Tracking** - Proxy-based deep reactivity with lazy signal creation per property
11
- - **Framework-Agnostic** - Use the best patterns from Svelte, Angular, Solid, and Vue in any environment
12
- - **Zero Dependencies** - Pure TypeScript, no runtime dependencies
13
- - **Automatic Memory Cleanup** - FinalizationRegistry ensures signals are garbage collected properly
14
- - **Battle-Tested Patterns** - Based on proven reactivity systems from major frameworks
9
+ - **Fine-Grained Reactivity** - Changes only trigger effects that depend on them
10
+ - **Deep Reactivity** - Proxy-based tracking at any nesting depth
11
+ - **Zero Dependencies** - Pure TypeScript, ~8KB minified
12
+ - **Framework Patterns** - linkedSignal (Angular), createSelector (Solid), effectScope (Vue)
13
+ - **Sync & Async Effects** - Choose predictable timing or automatic batching
14
+ - **Automatic Cleanup** - FinalizationRegistry ensures proper garbage collection
15
+
16
+ ## Performance
17
+
18
+ Benchmarked against Preact Signals (the fastest mainstream implementation):
19
+
20
+ | Operation | @rlabs-inc/signals | Preact Signals |
21
+ |-----------|-------------------|----------------|
22
+ | Signal read | **2ns** | 3ns |
23
+ | Signal write | 15ns | 12ns |
24
+ | Effect run | **20ns** | 25ns |
25
+ | Derived read | 5ns | 5ns |
26
+
27
+ Performance is competitive while offering significantly more features.
15
28
 
16
29
  ## Installation
17
30
 
@@ -26,18 +39,18 @@ npm install @rlabs-inc/signals
26
39
  ```typescript
27
40
  import { signal, derived, effect, flushSync } from '@rlabs-inc/signals'
28
41
 
29
- // Create a signal
42
+ // Create a signal (reactive value)
30
43
  const count = signal(0)
31
44
 
32
- // Create a derived value
45
+ // Create a derived (computed value)
33
46
  const doubled = derived(() => count.value * 2)
34
47
 
35
- // Create an effect
48
+ // Create an effect (side effect)
36
49
  effect(() => {
37
50
  console.log(`Count: ${count.value}, Doubled: ${doubled.value}`)
38
51
  })
39
52
 
40
- // Flush to run effects synchronously
53
+ // Flush to run effects synchronously (for testing/demos)
41
54
  flushSync() // Logs: "Count: 0, Doubled: 0"
42
55
 
43
56
  // Update the signal
@@ -47,6 +60,37 @@ flushSync() // Logs: "Count: 5, Doubled: 10"
47
60
 
48
61
  ---
49
62
 
63
+ ## Table of Contents
64
+
65
+ - [Core Primitives](#core-primitives)
66
+ - [signal](#signal)
67
+ - [signals](#signals)
68
+ - [derived](#derived)
69
+ - [state](#state)
70
+ - [stateRaw](#stateraw)
71
+ - [Effects](#effects)
72
+ - [effect()](#effect-async)
73
+ - [effect.sync()](#effectsync)
74
+ - [effect.root()](#effectroot)
75
+ - [effect.tracking()](#effecttracking)
76
+ - [Advanced Primitives](#advanced-primitives)
77
+ - [linkedSignal](#linkedsignal-angulars-killer-feature)
78
+ - [createSelector](#createselector-solids-on-to-o2-optimization)
79
+ - [effectScope](#effectscope-vues-lifecycle-management)
80
+ - [Bindings & Slots](#bindings--slots)
81
+ - [bind](#bind)
82
+ - [slot](#slot)
83
+ - [slotArray](#slotarray)
84
+ - [trackedSlotArray](#trackedslotarray)
85
+ - [reactiveProps](#reactiveprops)
86
+ - [Deep Reactivity](#deep-reactivity)
87
+ - [Utilities](#utilities)
88
+ - [Reactive Collections](#reactive-collections)
89
+ - [Equality Functions](#equality-functions)
90
+ - [Error Handling](#error-handling)
91
+
92
+ ---
93
+
50
94
  ## Core Primitives
51
95
 
52
96
  ### signal
@@ -60,7 +104,10 @@ signal<T>(initialValue: T, options?: { equals?: (a: T, b: T) => boolean }): Writ
60
104
  ```typescript
61
105
  const name = signal('John')
62
106
  console.log(name.value) // 'John'
63
- name.value = 'Jane' // Triggers effects
107
+ name.value = 'Jane' // Triggers effects that depend on it
108
+
109
+ // With custom equality (skip updates when structurally equal)
110
+ const user = signal({ name: 'John' }, { equals: deepEquals })
64
111
  ```
65
112
 
66
113
  ### signals
@@ -83,6 +130,30 @@ ui.content.value = 'updated'
83
130
  ui.width.value = 60
84
131
  ```
85
132
 
133
+ ### derived
134
+
135
+ Create a computed value that automatically updates when dependencies change.
136
+
137
+ ```typescript
138
+ derived<T>(fn: () => T, options?: { equals?: Equals<T> }): DerivedSignal<T>
139
+ ```
140
+
141
+ ```typescript
142
+ const firstName = signal('John')
143
+ const lastName = signal('Doe')
144
+
145
+ const fullName = derived(() => `${firstName.value} ${lastName.value}`)
146
+ console.log(fullName.value) // 'John Doe'
147
+
148
+ firstName.value = 'Jane'
149
+ console.log(fullName.value) // 'Jane Doe'
150
+ ```
151
+
152
+ Deriveds are:
153
+ - **Lazy** - Only computed when read
154
+ - **Cached** - Value is memoized until dependencies change
155
+ - **Pure** - Cannot write to signals inside (throws error)
156
+
86
157
  ### state
87
158
 
88
159
  Create a deeply reactive object. No `.value` needed - access properties directly.
@@ -94,10 +165,13 @@ state<T extends object>(initialValue: T): T
94
165
  ```typescript
95
166
  const user = state({
96
167
  name: 'John',
97
- address: { city: 'NYC' }
168
+ address: { city: 'NYC', zip: '10001' }
98
169
  })
99
- user.name = 'Jane' // Reactive
100
- user.address.city = 'LA' // Also reactive, deeply
170
+
171
+ // All property access is reactive
172
+ user.name = 'Jane' // Triggers effects reading user.name
173
+ user.address.city = 'LA' // Triggers effects reading user.address.city
174
+ // Effects reading user.name are NOT triggered by city change (fine-grained!)
101
175
  ```
102
176
 
103
177
  ### stateRaw
@@ -119,34 +193,15 @@ Use `stateRaw` for:
119
193
  - Class instances
120
194
  - Large objects where you only care about replacement
121
195
 
122
- ### derived
123
-
124
- Create a computed value that automatically updates when dependencies change.
125
-
126
- ```typescript
127
- derived<T>(fn: () => T, options?: { equals?: Equals<T> }): DerivedSignal<T>
128
- ```
129
-
130
- ```typescript
131
- const firstName = signal('John')
132
- const lastName = signal('Doe')
133
-
134
- const fullName = derived(() => `${firstName.value} ${lastName.value}`)
135
- console.log(fullName.value) // 'John Doe'
136
- ```
137
-
138
- Deriveds are:
139
- - **Lazy** - Only computed when read
140
- - **Cached** - Value is memoized until dependencies change
141
- - **Pure** - Cannot write to signals inside (throws error)
142
-
143
196
  ---
144
197
 
145
198
  ## Effects
146
199
 
147
- ### effect
200
+ Effects are the bridge between reactive state and the outside world. They re-run when their dependencies change.
148
201
 
149
- Create a side effect that re-runs when dependencies change.
202
+ ### effect() (async)
203
+
204
+ Create an effect that runs asynchronously via microtask. Multiple signal changes are automatically batched.
150
205
 
151
206
  ```typescript
152
207
  effect(fn: () => void | CleanupFn): DisposeFn
@@ -158,41 +213,75 @@ const count = signal(0)
158
213
  const dispose = effect(() => {
159
214
  console.log('Count is:', count.value)
160
215
 
161
- // Optional cleanup function
162
- return () => {
163
- console.log('Cleaning up...')
164
- }
216
+ // Optional cleanup function - runs before next execution
217
+ return () => console.log('Cleaning up...')
165
218
  })
166
219
 
220
+ count.value = 1
221
+ count.value = 2
222
+ count.value = 3
223
+ // Effect runs ONCE with final value (3) on next microtask
224
+
167
225
  // Stop the effect
168
226
  dispose()
169
227
  ```
170
228
 
171
- ### effect.pre
229
+ **When to use:** Most UI work, general reactivity. The automatic batching provides better throughput.
230
+
231
+ ### effect.sync()
172
232
 
173
- Create an effect that runs synchronously (like `$effect.pre` in Svelte).
233
+ Create a synchronous effect that runs immediately when dependencies change. Combine with `batch()` for best performance.
174
234
 
175
235
  ```typescript
176
- effect.pre(() => {
177
- // Runs immediately, not on microtask
236
+ effect.sync(fn: () => void | CleanupFn): DisposeFn
237
+ ```
238
+
239
+ ```typescript
240
+ const count = signal(0)
241
+
242
+ effect.sync(() => {
243
+ console.log('Count:', count.value)
178
244
  })
245
+ // Logs: "Count: 0" (runs immediately)
246
+
247
+ count.value = 1 // Logs: "Count: 1" (runs immediately)
248
+ count.value = 2 // Logs: "Count: 2" (runs immediately)
249
+
250
+ // For better performance with multiple writes, use batch():
251
+ batch(() => {
252
+ count.value = 10
253
+ count.value = 20
254
+ count.value = 30
255
+ })
256
+ // Logs: "Count: 30" (runs once at end of batch)
179
257
  ```
180
258
 
181
- ### effect.root
259
+ **When to use:**
260
+ - Debugging (predictable execution order)
261
+ - Testing (synchronous assertions)
262
+ - Sequential logic where timing matters
263
+ - When you need immediate side effects
182
264
 
183
- Create an effect scope that can contain nested effects.
265
+ ### effect.root()
266
+
267
+ Create a root effect scope that can contain nested effects.
268
+
269
+ ```typescript
270
+ effect.root(fn: () => void): DisposeFn
271
+ ```
184
272
 
185
273
  ```typescript
186
274
  const dispose = effect.root(() => {
187
- effect(() => { /* ... */ })
188
- effect(() => { /* ... */ })
275
+ effect(() => console.log('Effect A'))
276
+ effect(() => console.log('Effect B'))
277
+ effect.sync(() => console.log('Sync Effect C'))
189
278
  })
190
279
 
191
- // Disposes all nested effects
280
+ // Later, clean up ALL nested effects at once
192
281
  dispose()
193
282
  ```
194
283
 
195
- ### effect.tracking
284
+ ### effect.tracking()
196
285
 
197
286
  Check if currently inside a reactive tracking context.
198
287
 
@@ -202,86 +291,195 @@ if (effect.tracking()) {
202
291
  }
203
292
  ```
204
293
 
205
- ---
294
+ ### effect.pre (deprecated)
206
295
 
207
- ## Bindings
296
+ Alias for `effect.sync()`. Use `effect.sync()` instead for clarity.
208
297
 
209
- Two-way reactive pointers that forward reads and writes to a source signal.
298
+ ---
210
299
 
211
- ### bind
300
+ ## Advanced Primitives
212
301
 
213
- Create a reactive binding.
302
+ ### linkedSignal (Angular's killer feature)
303
+
304
+ Create a writable signal that derives from a source but can be manually overridden. When the source changes, the linked signal resets to the computed value.
214
305
 
215
306
  ```typescript
216
- bind<T>(source: WritableSignal<T> | Binding<T> | T | (() => T)): Binding<T>
307
+ linkedSignal<D>(fn: () => D): WritableSignal<D>
308
+ linkedSignal<S, D>(options: LinkedSignalOptions<S, D>): WritableSignal<D>
217
309
  ```
218
310
 
311
+ **Simple form - dropdown selection:**
312
+
219
313
  ```typescript
220
- const source = signal(0)
221
- const binding = bind(source)
314
+ const options = signal(['a', 'b', 'c'])
315
+ const selected = linkedSignal(() => options.value[0])
222
316
 
223
- // Reading through binding reads from source
224
- console.log(binding.value) // 0
317
+ console.log(selected.value) // 'a'
318
+ selected.value = 'b' // Manual override
319
+ console.log(selected.value) // 'b'
225
320
 
226
- // Writing through binding writes to source
227
- binding.value = 42
228
- console.log(source.value) // 42
321
+ options.value = ['x', 'y'] // Source changes
322
+ flushSync()
323
+ console.log(selected.value) // 'x' (reset to first item)
229
324
  ```
230
325
 
231
- **Overloads:**
232
- - `bind(signal)` - Creates writable binding to signal
233
- - `bind(binding)` - Chains bindings (both point to same source)
234
- - `bind(value)` - Wraps raw value in a signal
235
- - `bind(() => expr)` - Creates read-only binding from getter
326
+ **Advanced form - keep valid selection:**
236
327
 
237
- **Use cases:**
238
- - Two-way binding for form inputs
239
- - Connecting parent state to child components
240
- - Creating reactive links between signals
328
+ ```typescript
329
+ const items = signal([1, 2, 3])
330
+ const selectedItem = linkedSignal({
331
+ source: () => items.value,
332
+ computation: (newItems, prev) => {
333
+ // Keep selection if still valid
334
+ if (prev && newItems.includes(prev.value)) {
335
+ return prev.value
336
+ }
337
+ return newItems[0]
338
+ }
339
+ })
340
+ ```
341
+
342
+ **Form input that resets when data reloads:**
343
+
344
+ ```typescript
345
+ const user = signal({ name: 'Alice' })
346
+ const editName = linkedSignal(() => user.value.name)
347
+
348
+ // User types in input
349
+ editName.value = 'Bob'
350
+ console.log(editName.value) // 'Bob'
241
351
 
242
- ### bindReadonly
352
+ // When user data reloads from server
353
+ user.value = { name: 'Charlie' }
354
+ flushSync()
355
+ console.log(editName.value) // 'Charlie' (reset!)
356
+ ```
243
357
 
244
- Create a read-only binding.
358
+ ### createSelector (Solid's O(n) to O(2) optimization)
359
+
360
+ Create a selector function for efficient list selection tracking. Instead of O(n) effects re-running, only affected items run = O(2).
245
361
 
246
362
  ```typescript
247
- const source = signal(0)
248
- const readonly = bindReadonly(source)
363
+ createSelector<T, U = T>(
364
+ source: () => T,
365
+ fn?: (key: U, value: T) => boolean
366
+ ): SelectorFn<T, U>
367
+ ```
368
+
369
+ ```typescript
370
+ const selectedId = signal(1)
371
+ const isSelected = createSelector(() => selectedId.value)
249
372
 
250
- console.log(readonly.value) // 0
251
- // readonly.value = 42 // TypeScript error + runtime error
373
+ // In a list of 1000 items:
374
+ items.forEach(item => {
375
+ effect(() => {
376
+ // Only runs when THIS item's selection state changes!
377
+ if (isSelected(item.id)) {
378
+ highlight(item)
379
+ } else {
380
+ unhighlight(item)
381
+ }
382
+ })
383
+ })
384
+
385
+ // When selectedId changes from 1 to 2:
386
+ // - Only item 1's effect runs (was selected, now not)
387
+ // - Only item 2's effect runs (was not selected, now is)
388
+ // - Other 998 items' effects DON'T run!
252
389
  ```
253
390
 
254
- ### isBinding / unwrap
391
+ ### effectScope (Vue's lifecycle management)
392
+
393
+ Create an effect scope to group effects for batch disposal with pause/resume support.
255
394
 
256
395
  ```typescript
257
- // Check if a value is a binding
258
- isBinding(maybeBinding) // true or false
396
+ effectScope(detached?: boolean): EffectScope
259
397
 
260
- // Get value from binding, signal, derived, or return plain value directly
261
- // Works with ALL reactive types for unified value access
262
- const count = signal(42)
263
- const doubled = derived(() => count.value * 2)
264
- const bound = bind(count)
398
+ interface EffectScope {
399
+ readonly active: boolean
400
+ readonly paused: boolean
401
+ run<R>(fn: () => R): R | undefined
402
+ stop(): void
403
+ pause(): void
404
+ resume(): void
405
+ }
406
+ ```
407
+
408
+ ```typescript
409
+ const scope = effectScope()
410
+
411
+ scope.run(() => {
412
+ effect(() => console.log(count.value))
413
+ effect(() => console.log(name.value))
265
414
 
266
- unwrap(count) // 42 (extracts .value from signal)
267
- unwrap(doubled) // 84 (extracts .value from derived)
268
- unwrap(bound) // 42 (extracts .value from binding)
269
- unwrap('static') // 'static' (returns plain values as-is)
415
+ // Register cleanup to run when scope stops
416
+ onScopeDispose(() => {
417
+ console.log('Cleaning up...')
418
+ })
419
+ })
420
+
421
+ // Pause execution temporarily
422
+ scope.pause()
423
+ count.value = 5 // Effect doesn't run
424
+
425
+ // Resume and run pending updates
426
+ scope.resume() // Now effect runs with value 5
427
+
428
+ // Later, dispose all effects at once
429
+ scope.stop() // Runs onScopeDispose callbacks
430
+ ```
431
+
432
+ **Related utilities:**
270
433
 
271
- // Great for mapping mixed arrays
272
- const arr: (string | Binding<string>)[] = ['static', bind(signal('dynamic'))]
273
- arr.map(unwrap) // ['static', 'dynamic']
434
+ ```typescript
435
+ // Register cleanup on current scope
436
+ onScopeDispose(() => clearInterval(timer))
437
+
438
+ // Get the currently active scope
439
+ const scope = getCurrentScope()
274
440
  ```
275
441
 
276
442
  ---
277
443
 
278
- ## Slots (bind() on steroids)
444
+ ## Bindings & Slots
445
+
446
+ ### bind
447
+
448
+ Create a reactive binding - a two-way pointer that forwards reads and writes to a source signal.
449
+
450
+ ```typescript
451
+ bind<T>(source: WritableSignal<T> | Binding<T> | T | (() => T)): Binding<T>
452
+ ```
279
453
 
280
- Slots are stable reactive cells that can point to different sources. Unlike `bind()`, a Slot is never replaced - you mutate its source. This ensures deriveds ALWAYS track changes correctly.
454
+ ```typescript
455
+ const source = signal(0)
456
+ const binding = bind(source)
457
+
458
+ // Reading through binding reads from source
459
+ console.log(binding.value) // 0
460
+
461
+ // Writing through binding writes to source
462
+ binding.value = 42
463
+ console.log(source.value) // 42
464
+ ```
465
+
466
+ **Overloads:**
467
+ - `bind(signal)` - Creates writable binding to signal
468
+ - `bind(binding)` - Chains bindings (both point to same source)
469
+ - `bind(value)` - Wraps raw value in a signal
470
+ - `bind(() => expr)` - Creates read-only binding from getter
471
+
472
+ ```typescript
473
+ // Read-only binding from getter
474
+ const count = signal(5)
475
+ const doubled = bind(() => count.value * 2)
476
+ console.log(doubled.value) // 10
477
+ // doubled.value = 20 // Would throw!
478
+ ```
281
479
 
282
480
  ### slot
283
481
 
284
- Create a single reactive slot.
482
+ Create a stable reactive cell that can point to different sources. Unlike `bind()`, a Slot is never replaced - you mutate its source.
285
483
 
286
484
  ```typescript
287
485
  slot<T>(initial?: T): Slot<T>
@@ -295,16 +493,12 @@ console.log(mySlot.value) // 'hello'
295
493
  mySlot.source = 'world'
296
494
  console.log(mySlot.value) // 'world'
297
495
 
298
- // Point to a signal
496
+ // Point to a signal (two-way binding!)
299
497
  const name = signal('Alice')
300
498
  mySlot.source = name
301
499
  console.log(mySlot.value) // 'Alice'
302
- name.value = 'Bob'
303
- console.log(mySlot.value) // 'Bob'
304
-
305
- // Write through to signal (two-way binding!)
306
- mySlot.set('Charlie')
307
- console.log(name.value) // 'Charlie'
500
+ mySlot.set('Bob') // Writes through to signal!
501
+ console.log(name.value) // 'Bob'
308
502
 
309
503
  // Point to a getter (read-only, auto-tracks)
310
504
  const count = signal(5)
@@ -320,7 +514,7 @@ console.log(mySlot.value) // 'Count: 10'
320
514
 
321
515
  ### slotArray
322
516
 
323
- Create an array of slots for efficient reactive arrays.
517
+ Create an array of slots for efficient reactive arrays (parallel array / ECS pattern).
324
518
 
325
519
  ```typescript
326
520
  slotArray<T>(defaultValue?: T): SlotArray<T>
@@ -343,6 +537,7 @@ const rawSlot = textContent.slot(0)
343
537
  ```
344
538
 
345
539
  **SlotArray methods:**
540
+
346
541
  | Method | Description |
347
542
  |--------|-------------|
348
543
  | `arr[i]` | Read value at index (auto-unwraps, auto-tracks) |
@@ -352,46 +547,77 @@ const rawSlot = textContent.slot(0)
352
547
  | `arr.ensureCapacity(n)` | Expand to at least n slots |
353
548
  | `arr.clear(i)` | Reset slot i to default |
354
549
 
355
- ### TUI-style use case
550
+ ### trackedSlotArray
356
551
 
357
- Slots are designed for frameworks like TUI where:
358
- 1. Components write sources to arrays
359
- 2. Pipelines read from arrays reactively
360
- 3. Two-way binding enables inputs
552
+ Create an array of slots with automatic dirty tracking - perfect for incremental computation and ECS-style architectures.
361
553
 
362
554
  ```typescript
363
- // Component setup
364
- const count = signal(0)
365
- textContent.setSource(index, () => `Count: ${count.value}`)
555
+ trackedSlotArray<T>(defaultValue?: T, dirtySet: ReactiveSet<number>): SlotArray<T>
556
+ ```
366
557
 
367
- // Pipeline (derived) reads automatically
368
- // When count changes, pipeline re-renders!
558
+ ```typescript
559
+ const dirty = new ReactiveSet<number>()
560
+ const positions = trackedSlotArray({ x: 0, y: 0 }, dirty)
561
+
562
+ // Set source - automatically marks index as dirty
563
+ positions.setSource(0, signal({ x: 10, y: 20 })) // dirty.has(0) === true
564
+
565
+ // setValue also marks dirty
566
+ positions.setValue(5, { x: 100, y: 200 }) // dirty.has(5) === true
369
567
 
370
- // For inputs - two-way binding
371
- const username = signal('')
372
- textContent.setSource(inputIndex, username)
373
- // User types:
374
- textContent.setValue(inputIndex, 'alice')
375
- // username.value is now 'alice'!
568
+ // Use dirty tracking for O(1) skips
569
+ const layout = derived(() => {
570
+ if (dirty.size === 0) return cachedLayout // Nothing changed!
571
+
572
+ // Process only dirty indices
573
+ for (const index of dirty) {
574
+ updateLayout(index, positions[index])
575
+ }
576
+
577
+ dirty.clear()
578
+ return computedLayout
579
+ })
376
580
  ```
377
581
 
582
+ **Three-level reactivity:**
583
+
584
+ 1. **Per-index tracking** - `dirty.has(5)` only subscribes to index 5
585
+ 2. **Size tracking** - `dirty.size` tracks count of dirty indices (perfect for "anything changed?" checks)
586
+ 3. **Version tracking** - Iterating `for (const i of dirty)` tracks structural changes
587
+
588
+ **Use cases:**
589
+
590
+ - **ECS systems** - Track which entities changed, skip unchanged ones
591
+ - **Layout engines** - Recompute only components with changed properties
592
+ - **Incremental compilation** - Track which modules changed
593
+ - **Dirty checking** - Skip expensive computations when nothing changed
594
+
595
+ **Comparison with slotArray:**
596
+
597
+ | Feature | slotArray | trackedSlotArray |
598
+ |---------|-----------|------------------|
599
+ | Basic functionality | ✅ | ✅ |
600
+ | Automatic dirty tracking | ❌ | ✅ |
601
+ | O(1) skip optimization | ❌ | ✅ |
602
+ | Drop-in replacement | - | ✅ (just add dirtySet param) |
603
+
378
604
  ### reactiveProps
379
605
 
380
606
  Normalize component props to a consistent reactive interface. Accepts static values, getter functions, or signals - returns an object where every property is a DerivedSignal.
381
607
 
382
608
  ```typescript
383
- reactiveProps<T>(rawProps: PropsInput<T>): ReactiveProps<T>
609
+ reactiveProps<T>(rawProps: T): ReactiveProps<UnwrapPropInputs<T>>
384
610
  ```
385
611
 
386
612
  ```typescript
387
613
  interface MyComponentProps {
388
- name: string
389
- count: number
390
- active: boolean
614
+ name: PropInput<string>
615
+ count: PropInput<number>
616
+ active: PropInput<boolean>
391
617
  }
392
618
 
393
- function MyComponent(rawProps: PropsInput<MyComponentProps>) {
394
- // Convert any mix of static/getter/signal props to consistent reactive interface
619
+ function MyComponent(rawProps: MyComponentProps) {
620
+ // Convert any mix of static/getter/signal props to consistent interface
395
621
  const props = reactiveProps(rawProps)
396
622
 
397
623
  // Everything is now a DerivedSignal - consistent .value access
@@ -412,147 +638,104 @@ MyComponent({ name: nameSignal, count: () => getCount(), active: activeSignal })
412
638
  | Consumer must know which props need getters | Consumer just passes values |
413
639
  | Component must handle multiple input types | Component always gets DerivedSignal |
414
640
  | Easy to forget `() =>` and get stale values | Props are always reactive |
415
- | Inconsistent patterns across components | One pattern everywhere |
416
641
 
417
642
  ---
418
643
 
419
- ## Advanced Features
420
-
421
- ### linkedSignal (Angular's killer feature)
644
+ ## Deep Reactivity
422
645
 
423
- Create a writable signal that derives from a source but can be manually overridden. When the source changes, the linked signal resets to the computed value.
646
+ ### proxy
424
647
 
425
- ```typescript
426
- linkedSignal<D>(fn: () => D): WritableSignal<D>
427
- linkedSignal<S, D>(options: LinkedSignalOptions<S, D>): WritableSignal<D>
428
- ```
648
+ Create a deeply reactive proxy (used internally by `state()`).
429
649
 
430
650
  ```typescript
431
- // Simple form - dropdown selection
432
- const options = signal(['a', 'b', 'c'])
433
- const selected = linkedSignal(() => options.value[0])
651
+ const obj = proxy({ a: { b: { c: 1 } } })
434
652
 
435
- console.log(selected.value) // 'a'
436
- selected.value = 'b' // Manual override
437
- console.log(selected.value) // 'b'
653
+ effect(() => console.log('c changed:', obj.a.b.c))
654
+ effect(() => console.log('a changed:', obj.a))
438
655
 
439
- options.value = ['x', 'y'] // Source changes
440
- flushSync()
441
- console.log(selected.value) // 'x' (reset to computed value)
656
+ obj.a.b.c = 2 // Only triggers first effect (fine-grained!)
442
657
  ```
443
658
 
659
+ ### toRaw
660
+
661
+ Get the original object from a proxy.
662
+
444
663
  ```typescript
445
- // Advanced form - keep valid selection
446
- const items = signal([1, 2, 3])
447
- const selectedItem = linkedSignal({
448
- source: () => items.value,
449
- computation: (newItems, prev) => {
450
- // Keep selection if still valid
451
- if (prev && newItems.includes(prev.value)) {
452
- return prev.value
453
- }
454
- return newItems[0]
455
- }
456
- })
664
+ const raw = toRaw(user) // Original non-reactive object
457
665
  ```
458
666
 
459
- **Use cases:**
460
- - Form inputs that reset when parent data changes
461
- - Selection state that persists within valid options
462
- - Derived values that can be temporarily overridden
463
-
464
- ### createSelector (Solid's O(n) to O(2) optimization)
667
+ ### isReactive
465
668
 
466
- Create a selector function for efficient list selection tracking. Instead of O(n) effects re-running, only affected items run = O(2).
669
+ Check if a value is a reactive proxy.
467
670
 
468
671
  ```typescript
469
- createSelector<T, U = T>(
470
- source: () => T,
471
- fn?: (key: U, value: T) => boolean
472
- ): SelectorFn<T, U>
672
+ if (isReactive(value)) {
673
+ console.log('This is a proxy')
674
+ }
473
675
  ```
474
676
 
475
- ```typescript
476
- const selectedId = signal(1)
477
- const isSelected = createSelector(() => selectedId.value)
677
+ ---
478
678
 
479
- // In a list of 1000 items:
480
- items.forEach(item => {
481
- effect(() => {
482
- // Only runs when THIS item's selection state changes!
483
- // When selectedId changes from 1 to 2:
484
- // - Only item 1's effect runs (was selected, now not)
485
- // - Only item 2's effect runs (was not selected, now is)
486
- // - Other 998 items' effects DON'T run
487
- if (isSelected(item.id)) {
488
- highlight(item)
489
- } else {
490
- unhighlight(item)
491
- }
492
- })
493
- })
494
- ```
679
+ ## Utilities
495
680
 
496
- ### effectScope (Vue's lifecycle management)
681
+ ### batch
497
682
 
498
- Create an effect scope to group effects for batch disposal with pause/resume support.
683
+ Batch multiple signal updates into a single effect run. Essential for performance when doing multiple writes.
499
684
 
500
685
  ```typescript
501
- effectScope(detached?: boolean): EffectScope
502
-
503
- interface EffectScope {
504
- readonly active: boolean
505
- readonly paused: boolean
506
- run<R>(fn: () => R): R | undefined
507
- stop(): void
508
- pause(): void
509
- resume(): void
510
- }
511
- ```
686
+ const a = signal(1)
687
+ const b = signal(2)
512
688
 
513
- ```typescript
514
- const scope = effectScope()
689
+ effect.sync(() => console.log(a.value + b.value))
515
690
 
516
- scope.run(() => {
517
- effect(() => console.log(count.value))
518
- effect(() => console.log(name.value))
691
+ // Without batch: effect runs twice
692
+ a.value = 10 // Effect runs
693
+ b.value = 20 // Effect runs again
519
694
 
520
- onScopeDispose(() => {
521
- console.log('Cleaning up...')
522
- })
695
+ // With batch: effect runs once with final values
696
+ batch(() => {
697
+ a.value = 100
698
+ b.value = 200
523
699
  })
700
+ // Effect runs once: 300
701
+ ```
524
702
 
525
- // Pause execution temporarily
526
- scope.pause()
527
- count.value = 5 // Effect doesn't run
703
+ ### untrack / peek
528
704
 
529
- // Resume and run pending updates
530
- scope.resume()
705
+ Read signals without creating dependencies.
531
706
 
532
- // Later, dispose all effects at once
533
- scope.stop()
707
+ ```typescript
708
+ effect(() => {
709
+ const a = count.value // Creates dependency
710
+ const b = untrack(() => other.value) // No dependency
711
+ })
712
+
713
+ // peek is an alias for untrack
714
+ const value = peek(() => signal.value)
534
715
  ```
535
716
 
536
- ### onScopeDispose
717
+ ### flushSync
537
718
 
538
- Register a cleanup function on the current scope.
719
+ Synchronously flush all pending effects. Useful for testing and ensuring effects have run.
539
720
 
540
721
  ```typescript
541
- scope.run(() => {
542
- const timer = setInterval(() => log('tick'), 1000)
543
- onScopeDispose(() => clearInterval(timer))
722
+ count.value = 5
723
+ flushSync() // Effects run NOW, not on next microtask
724
+
725
+ // Can also wrap a function
726
+ flushSync(() => {
727
+ count.value = 10
728
+ // Effects for this change run before flushSync returns
544
729
  })
545
730
  ```
546
731
 
547
- ### getCurrentScope
732
+ ### tick
548
733
 
549
- Get the currently active effect scope.
734
+ Wait for the next update cycle (async). Returns a promise that resolves after all pending effects have run.
550
735
 
551
736
  ```typescript
552
- const scope = getCurrentScope()
553
- if (scope) {
554
- // Inside a scope
555
- }
737
+ count.value = 5
738
+ await tick() // Effects have run
556
739
  ```
557
740
 
558
741
  ---
@@ -597,7 +780,7 @@ A Date with reactive getters/setters.
597
780
  const date = new ReactiveDate()
598
781
 
599
782
  effect(() => {
600
- console.log(date.getHours()) // Re-runs when time changes
783
+ console.log(date.getHours()) // Re-runs when hours change
601
784
  })
602
785
 
603
786
  date.setHours(12) // Triggers effect
@@ -605,128 +788,38 @@ date.setHours(12) // Triggers effect
605
788
 
606
789
  ---
607
790
 
608
- ## Utilities
609
-
610
- ### batch
611
-
612
- Batch multiple signal updates into a single effect run.
613
-
614
- ```typescript
615
- const a = signal(1)
616
- const b = signal(2)
617
-
618
- effect(() => console.log(a.value + b.value))
619
-
620
- batch(() => {
621
- a.value = 10
622
- b.value = 20
623
- })
624
- // Effect runs once with final values, not twice
625
- ```
626
-
627
- ### untrack / peek
628
-
629
- Read signals without creating dependencies.
630
-
631
- ```typescript
632
- effect(() => {
633
- const a = count.value // Creates dependency
634
- const b = untrack(() => other.value) // No dependency
635
- })
636
-
637
- // peek is an alias for untrack
638
- const value = peek(() => signal.value)
639
- ```
640
-
641
- ### flushSync
642
-
643
- Synchronously flush all pending effects.
644
-
645
- ```typescript
646
- count.value = 5
647
- flushSync() // Effects run NOW, not on next microtask
648
- ```
649
-
650
- ### tick
651
-
652
- Wait for the next update cycle (async).
653
-
654
- ```typescript
655
- count.value = 5
656
- await tick() // Effects have run
657
- ```
658
-
659
- ---
660
-
661
- ## Deep Reactivity
662
-
663
- ### proxy
664
-
665
- Create a deeply reactive proxy (used internally by `state()`).
666
-
667
- ```typescript
668
- const obj = proxy({ a: { b: { c: 1 } } })
669
-
670
- effect(() => console.log('c changed:', obj.a.b.c))
671
- effect(() => console.log('a changed:', obj.a))
672
-
673
- obj.a.b.c = 2 // Only triggers first effect (fine-grained!)
674
- ```
675
-
676
- ### toRaw
677
-
678
- Get the original object from a proxy.
679
-
680
- ```typescript
681
- const raw = toRaw(user) // Original non-reactive object
682
- ```
683
-
684
- ### isReactive
685
-
686
- Check if a value is a reactive proxy.
687
-
688
- ```typescript
689
- if (isReactive(value)) {
690
- console.log('This is a proxy')
691
- }
692
- ```
693
-
694
- ---
695
-
696
791
  ## Equality Functions
697
792
 
698
793
  Control when signals trigger updates:
699
794
 
700
795
  ```typescript
701
- import { signal, derived, equals, deepEquals, safeEquals, shallowEquals, neverEquals, alwaysEquals, createEquals } from '@rlabs-inc/signals'
796
+ import {
797
+ signal, derived,
798
+ equals, // Object.is (default for signals)
799
+ deepEquals, // Bun.deepEquals (default for derived)
800
+ safeEquals, // Handles NaN correctly
801
+ shallowEquals, // One level deep comparison
802
+ neverEquals, // Always trigger
803
+ alwaysEquals, // Never trigger
804
+ createEquals // Custom equality
805
+ } from '@rlabs-inc/signals'
702
806
 
703
- // signal() - uses Object.is (reference equality)
807
+ // signal() uses Object.is (reference equality)
704
808
  const a = signal(0)
705
809
 
706
- // derived() - uses Bun.deepEquals (structural equality) by default
707
- // This prevents unnecessary propagation when computed values are structurally identical
810
+ // derived() uses Bun.deepEquals (structural equality)
811
+ // Prevents unnecessary propagation when computed values are structurally identical
708
812
  const items = signal([1, 2, 3])
709
813
  const doubled = derived(() => items.value.map(x => x * 2))
710
- // If doubled produces [2, 4, 6] again, downstream effects won't re-run
711
814
 
712
- // Deep equality - uses Bun.deepEquals (36ns for small objects!)
815
+ // Deep equality for signals
713
816
  const c = signal({ a: 1 }, { equals: deepEquals })
714
817
  c.value = { a: 1 } // Won't trigger - deeply equal
715
818
 
716
- // Safe equality - handles NaN correctly
717
- const d = signal(NaN, { equals: safeEquals })
718
-
719
- // Shallow comparison - compares one level deep
720
- const e = signal({ a: 1 }, { equals: shallowEquals })
721
-
722
819
  // Always trigger updates
723
820
  const f = signal(0, { equals: neverEquals })
724
821
  f.value = 0 // Still triggers!
725
822
 
726
- // Never trigger updates
727
- const g = signal(0, { equals: alwaysEquals })
728
- g.value = 100 // Doesn't trigger
729
-
730
823
  // Custom equality
731
824
  const customEquals = createEquals((a, b) =>
732
825
  JSON.stringify(a) === JSON.stringify(b)
@@ -735,11 +828,12 @@ const h = signal([], { equals: customEquals })
735
828
  ```
736
829
 
737
830
  **Default equality by primitive:**
831
+
738
832
  | Primitive | Default | Reason |
739
833
  |-----------|---------|--------|
740
834
  | `signal()` | `Object.is` | User-controlled input - reference equality |
741
- | `derived()` | `Bun.deepEquals` | Computed output - structural equality prevents unnecessary work |
742
- | `linkedSignal()` | `Bun.deepEquals` | Computed output - structural equality |
835
+ | `derived()` | `deepEquals` | Computed output - structural equality prevents unnecessary work |
836
+ | `linkedSignal()` | `deepEquals` | Computed output - structural equality |
743
837
 
744
838
  ---
745
839
 
@@ -774,7 +868,7 @@ effect(() => {
774
868
  count.value = count.value + 1 // Always triggers itself
775
869
  })
776
870
 
777
- // GOOD - add a guard
871
+ // GOOD - add a guard or use untrack
778
872
  effect(() => {
779
873
  if (count.value < 100) {
780
874
  count.value++
@@ -784,30 +878,6 @@ effect(() => {
784
878
 
785
879
  ---
786
880
 
787
- ## Performance
788
-
789
- This library is designed for performance:
790
-
791
- | Operation | Complexity | Notes |
792
- |-----------|------------|-------|
793
- | Signal read/write | O(1) | Direct property access |
794
- | Derived read | O(1) | Cached after first computation |
795
- | Effect trigger | O(deps) | Only runs if dependencies change |
796
- | `batch()` | O(1) cycle | Multiple updates, single flush |
797
- | `createSelector()` | O(2) | Only changed items' effects run |
798
- | Proxy property access | O(1) | Per-property signal lookup |
799
- | `ReactiveMap.get()` | O(1) | Per-key tracking |
800
-
801
- **Key optimizations:**
802
- - **Lazy evaluation** - Deriveds only compute when read
803
- - **Version-based deduplication** - No duplicate dependency tracking
804
- - **Linked list effect tree** - O(1) effect insertion/removal
805
- - **Microtask batching** - Updates coalesce automatically
806
- - **Per-property signals** - Fine-grained updates at any depth
807
- - **FinalizationRegistry cleanup** - Automatic memory management
808
-
809
- ---
810
-
811
881
  ## Framework Comparison
812
882
 
813
883
  | Feature | @rlabs-inc/signals | Svelte 5 | Vue 3 | Angular | Solid.js |
@@ -815,11 +885,13 @@ This library is designed for performance:
815
885
  | `signal()` | `signal()` | `$state` | `ref()` | `signal()` | `createSignal()` |
816
886
  | `derived()` | `derived()` | `$derived` | `computed()` | `computed()` | `createMemo()` |
817
887
  | `effect()` | `effect()` | `$effect` | `watchEffect()` | `effect()` | `createEffect()` |
888
+ | `effect.sync()` | `effect.sync()` | `$effect.pre` | - | - | - |
818
889
  | Deep reactivity | `state()` | `$state` | `reactive()` | - | - |
819
890
  | `linkedSignal()` | Yes | - | - | Yes | - |
820
891
  | `createSelector()` | Yes | - | - | - | Yes |
821
892
  | `effectScope()` | Yes | - | Yes | - | - |
822
893
  | `slot()` / `slotArray()` | Yes | - | - | - | - |
894
+ | `reactiveProps()` | Yes | - | - | - | - |
823
895
  | Compiler required | No | Yes | No | No | No |
824
896
  | DOM integration | No | Yes | Yes | Yes | Yes |
825
897
 
@@ -851,6 +923,49 @@ set(src, 10)
851
923
 
852
924
  ---
853
925
 
926
+ ## Type Exports
927
+
928
+ ```typescript
929
+ import type {
930
+ // Core types
931
+ Signal,
932
+ Source,
933
+ Reaction,
934
+ Derived,
935
+ Effect,
936
+ Value,
937
+
938
+ // Public API types
939
+ ReadableSignal,
940
+ WritableSignal,
941
+ DerivedSignal,
942
+ DisposeFn,
943
+ CleanupFn,
944
+ EffectFn,
945
+
946
+ // Binding types
947
+ Binding,
948
+ ReadonlyBinding,
949
+
950
+ // Slot types
951
+ Slot,
952
+ SlotArray,
953
+
954
+ // Props types
955
+ PropInput,
956
+ PropsInput,
957
+ ReactiveProps,
958
+
959
+ // Advanced types
960
+ LinkedSignalOptions,
961
+ SelectorFn,
962
+ EffectScope,
963
+ Equals,
964
+ } from '@rlabs-inc/signals'
965
+ ```
966
+
967
+ ---
968
+
854
969
  ## License
855
970
 
856
971
  MIT