@pyreon/reactivity 0.24.4 → 0.24.6

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.
Files changed (44) hide show
  1. package/package.json +1 -4
  2. package/src/batch.ts +0 -196
  3. package/src/cell.ts +0 -72
  4. package/src/computed.ts +0 -313
  5. package/src/createSelector.ts +0 -109
  6. package/src/debug.ts +0 -134
  7. package/src/effect.ts +0 -467
  8. package/src/env.d.ts +0 -6
  9. package/src/index.ts +0 -60
  10. package/src/lpih.ts +0 -227
  11. package/src/manifest.ts +0 -660
  12. package/src/reactive-devtools.ts +0 -494
  13. package/src/reactive-trace.ts +0 -142
  14. package/src/reconcile.ts +0 -118
  15. package/src/resource.ts +0 -84
  16. package/src/scope.ts +0 -123
  17. package/src/signal.ts +0 -261
  18. package/src/store.ts +0 -250
  19. package/src/tests/batch.test.ts +0 -751
  20. package/src/tests/bind.test.ts +0 -84
  21. package/src/tests/branches.test.ts +0 -343
  22. package/src/tests/cell.test.ts +0 -159
  23. package/src/tests/computed.test.ts +0 -436
  24. package/src/tests/coverage-hardening.test.ts +0 -471
  25. package/src/tests/createSelector.test.ts +0 -291
  26. package/src/tests/debug.test.ts +0 -196
  27. package/src/tests/effect.test.ts +0 -464
  28. package/src/tests/fanout-repro.test.ts +0 -179
  29. package/src/tests/lpih-source-location.test.ts +0 -277
  30. package/src/tests/lpih.test.ts +0 -351
  31. package/src/tests/manifest-snapshot.test.ts +0 -96
  32. package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
  33. package/src/tests/reactive-devtools.test.ts +0 -296
  34. package/src/tests/reactive-trace.test.ts +0 -102
  35. package/src/tests/reconcile-security.test.ts +0 -45
  36. package/src/tests/resource.test.ts +0 -326
  37. package/src/tests/scope.test.ts +0 -231
  38. package/src/tests/signal.test.ts +0 -368
  39. package/src/tests/store.test.ts +0 -286
  40. package/src/tests/tracking.test.ts +0 -158
  41. package/src/tests/vue-parity.test.ts +0 -191
  42. package/src/tests/watch.test.ts +0 -246
  43. package/src/tracking.ts +0 -139
  44. package/src/watch.ts +0 -68
package/src/manifest.ts DELETED
@@ -1,660 +0,0 @@
1
- import { defineManifest } from '@pyreon/manifest'
2
-
3
- export default defineManifest({
4
- name: '@pyreon/reactivity',
5
- title: 'Complete API',
6
- tagline:
7
- 'Fine-grained reactivity: signal, computed, effect, batch, onCleanup, createStore, watch, createResource, untrack',
8
- description:
9
- 'Standalone reactive primitives — no DOM, no JSX, no framework dependency. Signals are callable functions (`count()` to read, `count.set(5)` to write, `count.update(n => n + 1)` to derive). Subscribers tracked via `Set<() => void>`; batch uses pointer swap for zero-allocation grouping. Every other Pyreon package builds on this foundation but `@pyreon/reactivity` can be used independently in Node, Bun, or browser scripts without any framework overhead.',
10
- category: 'universal',
11
- longExample: `import { signal, computed, effect, batch, onCleanup, createStore, watch, untrack } from "@pyreon/reactivity"
12
-
13
- // signal<T>() — callable function, NOT .value getter/setter
14
- const count = signal(0)
15
- count() // read (subscribes)
16
- count.set(5) // write
17
- count.update(n => n + 1) // derive
18
- count.peek() // read WITHOUT subscribing
19
-
20
- // computed<T>() — auto-tracked, memoized
21
- const doubled = computed(() => count() * 2)
22
-
23
- // effect() — re-runs when dependencies change
24
- const dispose = effect(() => {
25
- console.log("Count:", count())
26
- onCleanup(() => console.log("cleaning up"))
27
- })
28
-
29
- // batch() — group 3+ writes into a single notification pass
30
- batch(() => {
31
- count.set(10)
32
- count.set(20) // subscribers fire once, with 20
33
- })
34
-
35
- // watch(source, callback) — explicit dependency tracking
36
- watch(() => count(), (next, prev) => {
37
- console.log(\`changed from \${prev} to \${next}\`)
38
- })
39
-
40
- // createStore() — deeply reactive object (proxy-based)
41
- const store = createStore({ todos: [{ text: 'Learn Pyreon', done: false }] })
42
- store.todos[0].done = true // fine-grained update, no immer needed
43
-
44
- // untrack() — read signals without subscribing
45
- effect(() => {
46
- const current = count()
47
- const other = untrack(() => otherSignal()) // won't re-run when otherSignal changes
48
- })`,
49
- features: [
50
- 'signal<T>() — callable function with .set() and .update()',
51
- 'computed<T>() — auto-tracked memoized derivation',
52
- 'effect() / renderEffect() — side-effects with auto-tracking',
53
- 'batch() / nextTick() — write-grouping + flush awaiter',
54
- 'onCleanup() — register cleanup inside effects',
55
- 'watch(source, callback) — explicit reactive watcher',
56
- 'createSelector() — O(1) equality selector for keyed lists',
57
- 'cell<T>() — lighter alternative to signal() for direct subscribe()',
58
- 'createStore() / reconcile() / isStore() — deeply reactive proxy stores + structural diff',
59
- 'effectScope() / getCurrentScope() — scope-based lifecycle management',
60
- 'untrack() — read without subscribing',
61
- 'onSignalUpdate() / inspectSignal() / why() / getReactiveTrace() — debug instrumentation',
62
- 'setErrorHandler() — global hook for unhandled effect errors',
63
- 'Standalone — zero DOM, zero JSX, zero framework dependency',
64
- ],
65
- api: [
66
- {
67
- name: 'signal',
68
- kind: 'function',
69
- signature: '<T>(initialValue: T, options?: { name?: string }) => Signal<T>',
70
- summary:
71
- 'Create a reactive signal. The returned value is a CALLABLE FUNCTION — `count()` reads (and subscribes), `count.set(v)` writes, `count.update(fn)` derives, `count.peek()` reads without subscribing. This is NOT a `.value` getter/setter pattern (React/Vue) — Pyreon signals are functions. Optional `{ name }` for debugging; auto-injected by `@pyreon/vite-plugin` in dev mode.',
72
- example: `const count = signal(0)
73
- count() // 0 (subscribes to updates)
74
- count.set(5) // sets to 5
75
- count.update(n => n + 1) // 6
76
- count.peek() // 6 (does NOT subscribe)`,
77
- mistakes: [
78
- '`count.value` — does not exist. Use `count()` to read',
79
- '`count = 5` — reassigning the variable replaces the signal, does not write to it. Use `count.set(5)`',
80
- '`signal(5)` called with an argument after creation — reads and ignores the argument (dev mode warns). Use `.set(5)` to write',
81
- '`const [val, setVal] = signal(0)` — signals are not destructurable tuples. The whole return value IS the signal',
82
- '`{count}` in JSX — renders the signal function itself, not its value. Use `{count()}` or `{() => count()}`',
83
- '`.peek()` inside `effect()` / `computed()` — bypasses tracking, creates stale reads. Only use `.peek()` for loop-prevention guards',
84
- ],
85
- seeAlso: ['computed', 'effect', 'batch'],
86
- },
87
- {
88
- name: 'computed',
89
- kind: 'function',
90
- signature: '<T>(fn: () => T, options?: { equals?: (a: T, b: T) => boolean }) => Computed<T>',
91
- summary:
92
- 'Create a memoized derived value. Dependencies auto-tracked on each evaluation — no dependency array needed (unlike React `useMemo`). Only recomputes when a tracked signal actually changes. Custom `equals` function prevents downstream effects from firing on structurally-equal updates (default: `Object.is`).',
93
- example: `const count = signal(0)
94
- const doubled = computed(() => count() * 2)
95
- doubled() // 0
96
- count.set(5)
97
- doubled() // 10`,
98
- mistakes: [
99
- '`computed(() => count)` — must CALL the signal: `computed(() => count())`',
100
- 'Using `computed()` for side effects — use `effect()` instead; computed is for pure derivation',
101
- 'Expecting `computed()` to re-run when a `.peek()`-read signal changes — `.peek()` bypasses tracking',
102
- ],
103
- seeAlso: ['signal', 'effect'],
104
- },
105
- {
106
- name: 'effect',
107
- kind: 'function',
108
- signature: '(fn: () => (() => void) | void) => () => void',
109
- summary:
110
- 'Run a side effect that auto-tracks signal dependencies and re-runs when they change. Returns a dispose function that unsubscribes. The effect function can return a cleanup callback (equivalent to calling `onCleanup()` inside the body) — the cleanup runs before each re-execution and on final dispose. For DOM-specific effects with lighter overhead, use `renderEffect()` instead.',
111
- example: `const count = signal(0)
112
- const dispose = effect(() => {
113
- console.log("Count:", count())
114
- onCleanup(() => console.log("cleaning up"))
115
- })
116
- // Or return cleanup directly:
117
- effect(() => {
118
- const handler = () => console.log(count())
119
- window.addEventListener("resize", handler)
120
- return () => window.removeEventListener("resize", handler)
121
- })`,
122
- mistakes: [
123
- 'Passing a dependency array — Pyreon auto-tracks; no array needed',
124
- '`effect(() => { count })` — must call the signal: `effect(() => { count() })`',
125
- 'Nesting `effect()` inside `effect()` — use `computed()` for derived values instead',
126
- 'Creating signals inside an effect — they re-create on every run; create once outside',
127
- ],
128
- seeAlso: ['onCleanup', 'computed', 'renderEffect'],
129
- },
130
- {
131
- name: 'renderEffect',
132
- kind: 'function',
133
- signature: '(fn: () => void) => () => void',
134
- summary:
135
- 'DOM-specific effect with a lighter dependency tracking path — uses a local array for deps instead of the full `EffectScope` integration. Used internally by `_bind` / `_tpl` for compiled-template DOM updates. **Prefer `effect()` for general use**; reach for `renderEffect()` only when you\'re hand-writing DOM update logic and have measured the overhead difference. Returns a dispose function (not an `Effect` object — different shape from `effect()`).',
136
- example: `// Inside a custom DOM helper that updates a text node:
137
- const node = document.createTextNode('')
138
- const dispose = renderEffect(() => {
139
- node.data = String(count())
140
- })
141
- // Re-runs only when count() changes; lighter than effect() but no
142
- // onCleanup support, no scope auto-disposal, no error-handler routing.`,
143
- mistakes: [
144
- 'Calling `onCleanup()` inside `renderEffect()` — not supported; only `effect()` collects cleanups. Use `effect()` if you need cleanup callbacks',
145
- 'Expecting `renderEffect()` to auto-dispose with the surrounding scope — it does NOT register with `EffectScope`. Component-scoped DOM effects should use `effect()` so they tear down on unmount',
146
- 'Reaching for `renderEffect()` as the default — `effect()` is the canonical primitive. The performance delta only matters in extreme hot paths (1000+ DOM nodes), never in component-level code',
147
- ],
148
- seeAlso: ['effect', 'computed'],
149
- },
150
- {
151
- name: 'batch',
152
- kind: 'function',
153
- signature: '(fn: () => void) => void',
154
- summary:
155
- 'Group multiple signal writes so subscribers fire only once — after the batch completes. Uses pointer swap (zero allocation). Essential when updating 3+ signals that downstream effects read together; without batch, each `.set()` triggers an independent notification pass.',
156
- example: `const a = signal(1)
157
- const b = signal(2)
158
- batch(() => {
159
- a.set(10)
160
- b.set(20)
161
- })
162
- // Effects that read both a() and b() fire once, not twice`,
163
- mistakes: [
164
- 'Reading a signal inside `batch()` and expecting the NEW value before the batch completes — reads inside the batch see the new value (writes are synchronous), but effects fire only after the batch callback returns',
165
- 'Forgetting `batch()` when updating 3+ related signals — causes N intermediate re-renders',
166
- ],
167
- seeAlso: ['signal', 'effect'],
168
- },
169
- {
170
- name: 'nextTick',
171
- kind: 'function',
172
- signature: '() => Promise<void>',
173
- summary:
174
- 'Returns a promise that resolves after the next microtask. Use to await pending reactive updates — every signal write that happens before `nextTick()` is fully flushed (effects ran, computeds settled, DOM patched) by the time the promise resolves. Equivalent to Vue\'s `nextTick`. Useful in tests and in code that needs to read the post-update DOM state.',
175
- example: `count.set(5)
176
- // Effects haven't run yet (sync writes are queued)
177
- await nextTick()
178
- // Now everything is flushed — DOM reflects count = 5
179
- expect(node.textContent).toBe('5')`,
180
- mistakes: [
181
- 'Awaiting `nextTick()` inside a `batch()` callback — pointless; the batch flushes when the callback returns, not when the microtask drains. Move the await outside `batch()`',
182
- 'Using `nextTick()` to defer work — it doesn\'t schedule anything; it just resolves on the next microtask. Use `setTimeout` / `requestAnimationFrame` for actual deferral',
183
- ],
184
- seeAlso: ['batch'],
185
- },
186
- {
187
- name: 'onCleanup',
188
- kind: 'function',
189
- signature: '(fn: () => void) => void',
190
- summary:
191
- 'Register a cleanup function inside an `effect()` or `renderEffect()`. Runs before each re-execution of the effect (when dependencies change) and once on final dispose. Equivalent to returning a cleanup function from the effect body — both forms work, `onCleanup` is useful when you need to register cleanup at a different point than the end of the body.',
192
- example: `effect(() => {
193
- const handler = () => console.log(count())
194
- window.addEventListener("resize", handler)
195
- onCleanup(() => window.removeEventListener("resize", handler))
196
- })`,
197
- mistakes: [
198
- 'Using `onCleanup` outside an effect — it only works inside `effect()` or `renderEffect()` body',
199
- 'Confusing with `onUnmount` — `onCleanup` is for effects, `onUnmount` is for component lifecycle',
200
- ],
201
- seeAlso: ['effect'],
202
- },
203
- {
204
- name: 'watch',
205
- kind: 'function',
206
- signature: '<T>(source: () => T, callback: (next: T, prev: T) => void, options?: WatchOptions) => () => void',
207
- summary:
208
- 'Explicit reactive watcher — tracks `source` and fires `callback` when it changes. Unlike `effect()`, the callback receives both `next` and `prev` values and does NOT auto-track signals read inside the callback body. `source` is evaluated at setup time to establish tracking; reading browser globals there still fires SSR lint rules. Returns a dispose function.',
209
- example: `watch(() => count(), (next, prev) => {
210
- console.log(\`changed from \${prev} to \${next}\`)
211
- })`,
212
- mistakes: [
213
- 'Reading browser globals in the `source` function — it runs at setup time (not just in mounted context), so `no-window-in-ssr` fires on `window.X` there',
214
- 'Expecting signals read inside the `callback` to be tracked — only the `source` function establishes tracking; the callback is untracked',
215
- 'Forgetting to return a cleanup function from the callback — `watch` honors a returned function as a cleanup that runs before each re-run AND on dispose. Useful for cancelling in-flight requests, clearing timers, or removing listeners attached on the previous run',
216
- ],
217
- seeAlso: ['effect', 'computed'],
218
- },
219
- {
220
- name: 'createSelector',
221
- kind: 'function',
222
- signature: '<T>(source: () => T) => (value: T) => boolean',
223
- summary:
224
- 'Create an O(1) equality selector — returns a reactive predicate that fires only when the previously-selected and newly-selected values\' subscribers are affected. Unlike a plain `() => source() === value` (which re-evaluates for every row in a list), this only triggers TWO subscribers per source change (deselected + newly selected) regardless of list size. Critical for keyed-list selection patterns.',
225
- example: `const selectedId = signal<string | null>(null)
226
- const isSelected = createSelector(() => selectedId())
227
-
228
- // In each row's render — O(1) selection updates regardless of N rows:
229
- <For each={rows} by={r => r.id}>{row => (
230
- <li class={() => (isSelected(row.id) ? 'selected' : '')}>
231
- {row.label}
232
- </li>
233
- )}</For>`,
234
- mistakes: [
235
- 'Using a plain `() => source() === value` in lists — every row subscribes to source; selecting a row notifies ALL N rows (O(N))',
236
- 'Calling `isSelected` outside a reactive scope — returns the current value but doesn\'t subscribe',
237
- 'Using `createSelector` for non-equality predicates — it\'s purpose-built for `===` matching; for ranges or filters, use `computed()`',
238
- ],
239
- seeAlso: ['signal', 'computed'],
240
- },
241
- {
242
- name: 'cell',
243
- kind: 'function',
244
- signature: '<T>(value: T) => Cell<T>',
245
- summary:
246
- 'Lightweight reactive primitive — class-based alternative to `signal()`. **1 object allocation vs `signal()`\'s ~6 closures**, single-listener fast path (no Set allocated when ≤1 subscriber), methods on prototype shared across instances. **NOT callable as a getter** — does not integrate with effect dependency tracking. Use when you need reactive state but plan to subscribe directly via `.subscribe()` / `.listen()`, NOT via `effect()`. Ideal for keyed-list row labels where the subscription lifetime equals the row\'s lifetime.',
247
- example: `import { cell } from '@pyreon/reactivity'
248
-
249
- // Create a cell:
250
- const label = cell('Initial')
251
-
252
- // Read (no tracking — read inside an effect does NOT subscribe):
253
- label.peek() // 'Initial'
254
-
255
- // Write:
256
- label.set('Updated')
257
- label.update(s => s + '!')
258
-
259
- // Subscribe directly (returns disposer):
260
- const dispose = label.subscribe(() => console.log(label.peek()))
261
-
262
- // Fire-and-forget — no disposer (saves 1 closure allocation):
263
- label.listen(() => console.log('changed'))`,
264
- mistakes: [
265
- 'Using `label()` to read — Cells are NOT callable. Use `label.peek()` to read',
266
- 'Reading `label.peek()` inside `effect()` and expecting tracked re-runs — Cells don\'t integrate with effect tracking. Use `signal()` if you need automatic dependency tracking',
267
- 'Using `cell()` for ALL reactive state — only switch from `signal()` when you\'ve measured allocation pressure (1000+ instances) AND you don\'t need effect-based subscriptions',
268
- ],
269
- seeAlso: ['signal'],
270
- },
271
- {
272
- name: 'createStore',
273
- kind: 'function',
274
- signature: '<T extends object>(initial: T) => T',
275
- summary:
276
- 'Create a deeply reactive proxy-based object. Mutations at any depth trigger fine-grained updates — `store.todos[0].done = true` only re-runs effects that read `store.todos[0].done`, not effects that read `store.todos.length` or other items. No immer, no spread-copy, no `produce()` — just mutate. Works with nested plain objects and arrays. Built-in types with internal slots (`Map`, `Set`, `WeakMap`, `WeakSet`, `Date`, `RegExp`, `Promise`, `Error`) are returned raw and are NOT deeply reactive — they fail the Proxy internal-slot check on every method call. Replace the whole field (`store.users = new Map(store.users)`) to trigger reactivity for these.',
277
- example: `const store = createStore({
278
- todos: [{ text: 'Learn Pyreon', done: false }],
279
- filter: 'all',
280
- })
281
- store.todos[0].done = true // fine-grained — only 'done' subscribers fire
282
- store.todos.push({ text: 'Build app', done: false }) // array methods work`,
283
- mistakes: [
284
- 'Replacing the entire store object — `store = { ... }` replaces the variable, not the proxy. Mutate properties instead: `store.filter = "active"`',
285
- 'Destructuring store properties at setup — `const { filter } = store` captures the value once, losing reactivity. Read `store.filter` inside reactive scopes',
286
- 'Using `createStore` for simple scalar state — use `signal()` for primitives; `createStore` adds proxy overhead that only pays off for nested objects',
287
- 'Expecting fine-grained reactivity inside Map/Set/Date/RegExp/Promise — these are returned raw because Proxy can\'t intercept methods that rely on internal slots. Mutating the raw instance (`store.users.set(...)`) does NOT notify subscribers. Replace the whole field (`store.users = new Map(store.users)`) to trigger reactivity',
288
- ],
289
- seeAlso: ['signal'],
290
- },
291
- {
292
- name: 'createResource',
293
- kind: 'function',
294
- signature:
295
- '<T, P>(source: () => P, fetcher: (param: P) => Promise<T>) => Resource<T>',
296
- summary:
297
- 'Async data primitive. Auto-fetches whenever `source()` changes — `data`, `loading`, `error` are signals readable inside effects. Stale-response guarded via internal `requestId` (typing fast then slow does not flicker old data). `refetch()` re-runs the fetcher with the current source value. **`dispose()` MUST be called for resources created outside an `EffectScope`** — otherwise the source-tracking effect leaks for the lifetime of the program.',
298
- example: `const userId = signal(1)
299
- const user = createResource(
300
- () => userId(),
301
- (id) => fetch(\`/api/users/\${id}\`).then(r => r.json()),
302
- )
303
- effect(() => {
304
- if (user.loading()) return
305
- if (user.error()) return console.error(user.error())
306
- console.log(user.data())
307
- })
308
- userId.set(2) // auto-refetches
309
- user.refetch() // explicit refetch with current source
310
- user.dispose() // stop tracking, discard in-flight response`,
311
- mistakes: [
312
- 'Forgetting `dispose()` for resources outside an EffectScope — the internal source-tracking effect runs forever, leaking memory and unbounded fetch calls on source changes',
313
- 'Calling `refetch()` after `dispose()` — silently no-ops; check disposed state on your end if needed',
314
- 'Reading `data()` without checking `loading()` / `error()` — undefined values flow through; gate the read on those signals',
315
- 'Expecting an in-flight response to update the resource AFTER `dispose()` — the response is discarded by design (stale-id check), `loading` may stay frozen at its dispose-time value',
316
- 'Reading signals INSIDE the fetcher and expecting tracked re-runs — only `source()` is tracked; signals read inside `fetcher` are read once per call without subscription',
317
- ],
318
- seeAlso: ['signal', 'effect', 'effectScope'],
319
- },
320
- {
321
- name: 'reconcile',
322
- kind: 'function',
323
- signature: '<T extends object>(source: T, target: T) => void',
324
- summary:
325
- 'Surgically diff a new value into an existing `createStore` proxy. Walks both trees in parallel and only calls `.set()` on signals whose value actually changed — unchanged subtrees do NOT re-run their effects. Ideal for applying API responses to a long-lived store: only the truly-changed fields trigger updates, even if you receive a fully-replacement payload from the server. Arrays reconcile by index; excess elements are removed.',
326
- example: `const state = createStore({ user: { name: 'Alice', age: 30 }, items: [] })
327
-
328
- // API response arrives — pure replacement payload:
329
- reconcile(
330
- { user: { name: 'Alice', age: 31 }, items: [{ id: 1 }] },
331
- state,
332
- )
333
- // → only state.user.age signal fires (name unchanged)
334
- // → state.items[0] is newly created, length signal fires`,
335
- mistakes: [
336
- 'Passing a non-store as `target` — `reconcile` requires a `createStore` proxy; for plain objects, just assign',
337
- 'Expecting reconciliation by key for arrays — arrays are reconciled BY INDEX. For keyed list reconciliation, use a Map keyed by id and reconcile each entry by key, OR replace the array reference (which `<For>` reconciles via `by`)',
338
- 'Using `reconcile` inside an effect — it triggers writes; you\'d cycle. Call it outside reactive scopes (e.g. in a query callback or event handler)',
339
- ],
340
- seeAlso: ['createStore', 'signal'],
341
- },
342
- {
343
- name: 'isStore',
344
- kind: 'function',
345
- signature: '(value: unknown) => boolean',
346
- summary:
347
- 'Type guard — returns `true` if the value is a `createStore` proxy (recognized via an internal symbol marker). Use to differentiate reactive stores from plain objects in code that handles both shapes (e.g. helpers that conditionally `reconcile()` vs assign).',
348
- example: `const a = createStore({ x: 1 })
349
- const b = { x: 1 }
350
- isStore(a) // true
351
- isStore(b) // false
352
- isStore(null) // false (null-safe)`,
353
- mistakes: [
354
- 'Using `isStore` to detect ANY proxy — it\'s specific to Pyreon\'s store proxies. Other proxies return `false`',
355
- 'Calling on `null` / `undefined` and expecting a throw — null-safe; returns `false`',
356
- ],
357
- seeAlso: ['createStore', 'reconcile'],
358
- },
359
- {
360
- name: 'shallowReactive',
361
- kind: 'function',
362
- signature: '<T extends object>(initial: T) => T',
363
- summary:
364
- 'Create a SHALLOW reactive store — only top-level mutations trigger updates. Nested objects are NOT auto-wrapped; reading a nested object returns the raw reference, and mutating it does NOT trigger any effect. Replacing the top-level reference DOES trigger reactivity. Use when nested data is immutable (frozen API responses), when you want explicit control over which subtrees are reactive, or when you need to store class instances/third-party objects without paying the deep-proxy overhead. Vue 3 parity.',
365
- example: `const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
366
- effect(() => store.count) // tracks store.count
367
- effect(() => store.user) // tracks store.user reference (not its contents)
368
- store.user.name = 'Bob' // does NOT trigger any effect (nested mutation)
369
- store.count = 5 // triggers count effect
370
- store.user = { name: 'Bob' } // triggers user effect (reference replacement)`,
371
- mistakes: [
372
- 'Expecting nested mutations to trigger effects — they don\'t. Use `createStore` if you need deep reactivity, or replace the top-level reference (`store.user = { ...store.user, name: \'Bob\' }`)',
373
- 'Mixing shallow + deep on the same raw object — `createStore(raw)` and `shallowReactive({ wrapper: raw })` produce DIFFERENT proxies (separate caches). Pick one shape per data flow',
374
- ],
375
- seeAlso: ['createStore', 'markRaw'],
376
- },
377
- {
378
- name: 'markRaw',
379
- kind: 'function',
380
- signature: '<T extends object>(value: T) => T',
381
- summary:
382
- 'Mark an object as RAW — `createStore` and `shallowReactive` will return it unwrapped. Useful for class instances, third-party objects, DOM nodes, or any shape that shouldn\'t be deeply proxied (Vue 3 parity). Marking is one-way: there\'s no `unmarkRaw`. Mark BEFORE the object enters a store; marking after wrap doesn\'t unwrap an existing proxy.',
383
- example: `import { markRaw, createStore } from '@pyreon/reactivity'
384
-
385
- class Editor { /* ... */ }
386
- const ed = markRaw(new Editor()) // skips proxy
387
- const store = createStore({ editor: ed })
388
- store.editor === ed // true — raw reference preserved
389
- store.editor.someMethod() // works — class methods see real receiver`,
390
- mistakes: [
391
- 'Marking an object AFTER it\'s been wrapped — the existing proxy is unaffected. Mark before the object enters any store',
392
- 'Expecting `markRaw(obj)` to return a different object — it mutates `obj` and returns the SAME reference (with the marker symbol attached)',
393
- 'Using markRaw on plain data objects to "skip" deep wrap — for that, use `shallowReactive`. markRaw is for class instances and externally-managed shapes',
394
- ],
395
- seeAlso: ['createStore', 'shallowReactive'],
396
- },
397
- {
398
- name: 'untrack',
399
- kind: 'function',
400
- signature: '(fn: () => T) => T',
401
- summary:
402
- 'Execute a function reading signals WITHOUT subscribing to them. Alias for `runUntracked`. Use inside effects when you need to read a signal\'s current value as a one-shot snapshot without the effect re-running when that signal changes.',
403
- example: `effect(() => {
404
- const current = count() // tracked — effect re-runs on count change
405
- const other = untrack(() => otherSignal()) // NOT tracked — just reads the current value
406
- })`,
407
- mistakes: [
408
- 'Using `untrack` as the default — signals should be tracked by default; `untrack` is the escape hatch for specific optimization or loop-prevention cases',
409
- ],
410
- seeAlso: ['signal', 'effect'],
411
- },
412
- {
413
- name: 'effectScope',
414
- kind: 'function',
415
- signature: '() => EffectScope',
416
- summary:
417
- 'Create an `EffectScope` — a container that auto-tracks effects/computeds created inside `scope.runInScope(fn)` and disposes them all at once via `scope.stop()`. `@pyreon/core`\'s `mountReactive` uses this internally for component lifetime management. **Always use a scope for effects created outside a component\'s setup phase** (e.g. in event handlers, route loaders, or async-await chains) — without one, effects leak for the lifetime of the program.',
418
- example: `import { effectScope, signal, effect } from '@pyreon/reactivity'
419
-
420
- const scope = effectScope()
421
- const count = signal(0)
422
-
423
- scope.runInScope(() => {
424
- effect(() => console.log(count())) // tracked by scope
425
- })
426
-
427
- count.set(5) // logs 5
428
- scope.stop() // tears down all effects in the scope
429
- count.set(10) // no log — effect was disposed`,
430
- mistakes: [
431
- 'Forgetting `scope.stop()` — effects leak for the lifetime of the program; same shape as forgetting `dispose()` on a top-level `effect()`',
432
- 'Creating effects outside `runInScope(fn)` and expecting them to be tracked — effects must run during the synchronous body of `runInScope` to register with the scope',
433
- 'Stopping a scope that has pending updates — in-flight microtasks may still fire `onUpdate` hooks; design for idempotency or check `isActive` before writes',
434
- ],
435
- seeAlso: ['effect', 'getCurrentScope', 'onScopeDispose'],
436
- },
437
- {
438
- name: 'onScopeDispose',
439
- kind: 'function',
440
- signature: '(fn: () => void) => void',
441
- summary:
442
- 'Register a callback to run when the current `EffectScope` stops. Vue 3 parity. Captures the AMBIENT scope at registration time, so it must be called inside `scope.runInScope(fn)`. Calling outside any scope is a no-op (with a dev warning). Use for resource cleanup tied to scope lifetime — timers, listeners, external subscriptions. Equivalent to `getCurrentScope()?.add({ dispose: fn })` but without the boilerplate.',
443
- example: `scope.runInScope(() => {
444
- const ws = new WebSocket(url)
445
- onScopeDispose(() => ws.close())
446
- // ws.close() runs when scope.stop() is called
447
- })`,
448
- mistakes: [
449
- 'Calling outside any scope — silently no-ops in production, dev warns. The callback is dropped on the floor; verify with `getCurrentScope()` before calling if scope is uncertain',
450
- 'Expecting the callback to run on EFFECT cleanup — `onScopeDispose` fires only on `scope.stop()`. For per-effect cleanup, use `onCleanup()` inside the effect body or return a cleanup function from it',
451
- 'Using outside `runInScope` and inside an effect callback — the effect captures whatever scope was ambient when the effect SET UP, not when the registration runs. Effects re-run later may see a different ambient scope; register at setup, not in the body',
452
- ],
453
- seeAlso: ['effectScope', 'getCurrentScope', 'onCleanup'],
454
- },
455
- {
456
- name: 'getCurrentScope',
457
- kind: 'function',
458
- signature: '() => EffectScope | null',
459
- summary:
460
- 'Returns the currently active `EffectScope` (the one whose `runInScope(fn)` is on the stack), or `null` if no scope is active. Use to register cleanup with the surrounding scope, or to detect "am I inside a component lifetime?" — useful for library code that wants to register an effect with the consumer\'s scope rather than the global one.',
461
- example: `import { getCurrentScope } from '@pyreon/reactivity'
462
-
463
- function myReactiveResource() {
464
- const scope = getCurrentScope()
465
- if (scope) {
466
- // Inside a component — register cleanup with the component's scope
467
- scope.add({ dispose: cleanup })
468
- } else {
469
- // Top-level / standalone — caller must call dispose() manually
470
- console.warn('myReactiveResource: no active scope; remember to dispose')
471
- }
472
- }`,
473
- mistakes: [
474
- 'Calling `getCurrentScope()` outside any scope and expecting a default — returns `null`. Handle the no-scope case explicitly',
475
- 'Using `getCurrentScope()` as a substitute for `effectScope()` — it returns the AMBIENT scope, not a fresh one',
476
- ],
477
- seeAlso: ['effectScope', 'setCurrentScope'],
478
- },
479
- {
480
- name: 'setCurrentScope',
481
- kind: 'function',
482
- signature: '(scope: EffectScope | null) => void',
483
- summary:
484
- '**Low-level escape hatch** — directly set the ambient `EffectScope`. Use only when implementing scope-aware framework primitives (e.g. `mountReactive`, custom render boundaries). Most code should use `scope.runInScope(fn)` which sets and restores via try/finally. Pairing `setCurrentScope(s)` with a manual `setCurrentScope(prev)` is error-prone — `runInScope` is the safe form.',
485
- example: `// Inside a custom render boundary that needs to swap scopes mid-flow:
486
- const prev = getCurrentScope()
487
- setCurrentScope(myScope)
488
- try {
489
- doWork()
490
- } finally {
491
- setCurrentScope(prev)
492
- }
493
- // Or — preferred:
494
- myScope.runInScope(() => doWork())`,
495
- mistakes: [
496
- 'Forgetting to restore the previous scope — leaks effects to the wrong owner forever',
497
- 'Using `setCurrentScope` instead of `runInScope` in user code — the safe API is `runInScope`',
498
- ],
499
- seeAlso: ['effectScope', 'getCurrentScope'],
500
- },
501
- {
502
- name: 'onSignalUpdate',
503
- kind: 'function',
504
- signature: '(listener: (event: { signal, name, prev, next, stack, timestamp }) => void) => () => void',
505
- summary:
506
- 'Register a global trace listener that fires on every signal write. Returns a disposer. **Dev/debug only** — every signal write incurs the listener call. Use for time-travel debugging, recording reactive transcripts in tests, or building devtools panels. Multiple listeners are supported (each gets every event).',
507
- example: `import { onSignalUpdate, signal } from '@pyreon/reactivity'
508
-
509
- const dispose = onSignalUpdate(e => {
510
- console.log(\`\${e.name ?? '(anonymous)'}: \${e.prev} → \${e.next}\`)
511
- })
512
- const count = signal(0, { name: 'count' })
513
- count.set(5) // logs: count: 0 → 5
514
- dispose() // remove listener`,
515
- mistakes: [
516
- 'Leaving `onSignalUpdate` registered in production — fires on EVERY signal write, even hot-path internal ones. Always dispose when done',
517
- 'Throwing inside the listener — corrupts the signal\'s notification flow (the listener fires after `_v` is updated but before subscribers are notified). Wrap your handler in try/catch',
518
- 'Expecting the event to capture writes that occur via batch flushes — the event fires per `set()` call, regardless of batch state',
519
- ],
520
- seeAlso: ['inspectSignal', 'why'],
521
- },
522
- {
523
- name: 'inspectSignal',
524
- kind: 'function',
525
- signature: '<T>(sig: Signal<T>) => SignalDebugInfo<T>',
526
- summary:
527
- 'Inspect a signal — pretty-prints its current value, name, and subscriber count to the console (in a `console.group`) and returns the debug info object. Useful for one-shot inspection while debugging; for continuous tracing use `onSignalUpdate`.',
528
- example: `const count = signal(0, { name: 'count' })
529
- inspectSignal(count)
530
- // Console group:
531
- // 🔍 Signal "count"
532
- // value: 0
533
- // subscribers: 2`,
534
- mistakes: [
535
- 'Calling `inspectSignal` in production — produces console noise. Gate calls behind `if (import.meta.env.DEV)` or `__DEV__`',
536
- ],
537
- seeAlso: ['onSignalUpdate', 'why'],
538
- },
539
- {
540
- name: 'why',
541
- kind: 'function',
542
- signature: '() => void',
543
- summary:
544
- 'Toggle a global "why-did-it-update?" tracer that logs every signal write between consecutive calls. Calling once arms the tracer; calling again disarms it and dumps the captured transcript. **Dev/debug only.** Useful for hunting "why did this effect just re-run?" — wrap a suspicious operation, call `why()` before and after, see exactly which signals changed.',
545
- example: `why() // arm tracer
546
- clickButton() // any signal writes here are captured
547
- why() // disarm + dump transcript:
548
- // [pyreon:why] "filter": "all" → "active" (12 subscribers)
549
- // [pyreon:why] "scrollY": 0 → 240 (1 subscriber)`,
550
- mistakes: [
551
- 'Calling `why()` once and forgetting to call it again — keeps tracing forever, leaks the listener, prints nothing until disarmed',
552
- 'Using `why()` in production — pure dev tool',
553
- ],
554
- seeAlso: ['onSignalUpdate', 'inspectSignal'],
555
- },
556
- {
557
- name: 'getReactiveTrace',
558
- kind: 'function',
559
- signature:
560
- '() => Array<{ name: string | undefined; prev: string; next: string; timestamp: number }>',
561
- summary:
562
- 'Returns the last ~50 signal writes (chronological, oldest → newest) from a bounded dev-only ring buffer — the causal SEQUENCE of reactive state changes, not a point-in-time snapshot. `@pyreon/core` attaches this to `ErrorContext.reactiveTrace` automatically so error reports carry "what changed in the run-up to the crash". Entries hold bounded string previews of values (never raw refs — no memory pinning, always serializable). **Dev-only**: the recorder feeding the buffer is behind the production dead-code gate and tree-shakes out, so this returns `[]` in prod builds. Distinct from `onSignalUpdate` — that is opt-in and captures stacks; this is always-on, deliberately cheap, and exists to enrich error reports. `clearReactiveTrace()` resets it (test isolation).',
563
- example: `import { getReactiveTrace, clearReactiveTrace, signal } from '@pyreon/reactivity'
564
-
565
- const status = signal('idle', { name: 'status' })
566
- status.set('submitting')
567
- getReactiveTrace()
568
- // [{ name: 'status', prev: '"idle"', next: '"submitting"', timestamp: 1234.5 }]
569
- clearReactiveTrace() // → []`,
570
- mistakes: [
571
- 'Expecting it to return signal VALUES — it returns string PREVIEWS (truncated, safely stringified). For live values inspect the signal directly',
572
- 'Relying on it in production — returns `[]` (the recorder is dev-gated and tree-shaken). Use it for dev tooling / error-report enrichment, not runtime logic',
573
- 'Treating it as a snapshot of all signals — it is a bounded ring of recent WRITES; signals never written (or written before the ~50-entry window) are absent',
574
- ],
575
- seeAlso: ['onSignalUpdate', 'inspectSignal'],
576
- },
577
- {
578
- name: 'setErrorHandler',
579
- kind: 'function',
580
- signature: '(fn: (err: unknown) => void) => void',
581
- summary:
582
- 'Register a global handler for unhandled errors thrown inside `effect()` / `computed()` / `renderEffect()`. Without a handler, errors are logged to `console.error` and the effect re-throws (potentially crashing the surrounding frame). With one, the framework calls your handler with the thrown value and continues. Use for telemetry / error-boundary integration. **One handler only — calling twice replaces the first.**',
583
- example: `setErrorHandler(err => {
584
- reportToSentry(err)
585
- toast.error('Something went wrong')
586
- })
587
-
588
- effect(() => {
589
- if (count() > 100) throw new Error('count too high')
590
- })
591
- count.set(101) // logs/reports via handler instead of crashing`,
592
- mistakes: [
593
- 'Calling `setErrorHandler` multiple times and expecting all to fire — the second call REPLACES the first. Compose multiple handlers manually if you need a chain',
594
- 'Throwing inside the handler — the framework will swallow this too, but you lose visibility. Make handlers no-throw (try/catch internally if needed)',
595
- 'Expecting the handler to receive errors from `signal.set()` writes — only effect-runtime errors are routed. Synchronous errors at write time bubble up normally',
596
- ],
597
- seeAlso: ['effect', 'renderEffect'],
598
- },
599
- {
600
- name: 'activateReactiveDevtools',
601
- kind: 'function',
602
- signature:
603
- 'activateReactiveDevtools(): void · deactivateReactiveDevtools(): void · isReactiveDevtoolsActive(): boolean',
604
- summary:
605
- 'Opt-in lifecycle for the reactive-devtools bridge — the live signal/computed/effect graph the `@pyreon/devtools` Signals/Graph/Effects/Profiler tabs consume (surfaced on the browser hook as `window.__PYREON_DEVTOOLS__.reactive`). **Zero cost until activated**: every per-primitive instrumentation point early-returns on the inactive flag and sits inside the production dead-code gate, so it tree-shakes out of prod builds entirely (locked by a minified-bundle test) and, in dev, costs one predicted-false branch until a devtools client calls `activate()` — the same risk profile as the adjacent reactive-trace / perf-harness calls. `deactivate()` drops all retained registry + fire-buffer state (a closed panel leaves zero residue). Leak-free by construction: nodes are held via `WeakRef` + `FinalizationRegistry`, never pinned.',
606
- example: `import { activateReactiveDevtools, getReactiveGraph } from '@pyreon/reactivity'
607
-
608
- // Only AFTER activation are subsequently-created signals tracked.
609
- activateReactiveDevtools()
610
- const price = signal(10, { name: '$price' })
611
- const total = computed(() => price() * 2)
612
- effect(() => total())
613
- getReactiveGraph().nodes // → [$price (signal), derived, effect]
614
- deactivateReactiveDevtools() // → registry cleared`,
615
- mistakes: [
616
- 'Expecting nodes created BEFORE `activate()` to appear — registration is gated on the active flag (mirrors a devtools panel attaching). Activate first, then build/observe the graph',
617
- 'Calling it in production for app logic — the whole bridge is dev-gated and tree-shaken; `getReactiveGraph()` returns an empty graph in prod builds',
618
- 'Assuming it tracks compiler-emitted DOM bindings — only user `signal()` / `computed()` / `effect()` are registered; `renderEffect` / `_bind` plumbing is intentionally excluded (it would flood the graph and tax the hottest path)',
619
- ],
620
- seeAlso: ['getReactiveGraph', 'onSignalUpdate', 'getReactiveTrace'],
621
- },
622
- {
623
- name: 'getReactiveGraph',
624
- kind: 'function',
625
- signature:
626
- 'getReactiveGraph(): { nodes: ReactiveNode[]; edges: { from: number; to: number }[] } · getReactiveFires(): { id: number; ts: number }[]',
627
- summary:
628
- 'Fresh snapshot of the live reactive graph + a bounded recent-fire timeline, for the reactive-devtools tabs. `getReactiveGraph()` returns every tracked node (`{ id, kind: "signal"|"derived"|"effect", name, value, subscribers, fires, lastFire }`) plus dependency edges recomputed on demand from the real subscriber `_s` Sets (source → subscriber: signal→derived, derived→effect) — always consistent with the framework’s actual subscription state, no incremental drift. `getReactiveFires()` returns a fixed-size ring buffer of recent fires (`{ id, ts }`, oldest → newest) powering the Effects/Profiler tabs. Both require `activateReactiveDevtools()` first and return empty otherwise. Names come from `signal(v, { name })` / the vite-plugin dev auto-naming; anonymous computeds/effects get a synthetic `derived#id` / `effect#id`.',
629
- example: `activateReactiveDevtools()
630
- const a = signal(1, { name: '$a' })
631
- const b = computed(() => a() + 1)
632
- effect(() => b())
633
- a.set(2)
634
- getReactiveGraph()
635
- // nodes: [{ name:'$a', kind:'signal', value:'2', … }, { kind:'derived', … }, { kind:'effect', … }]
636
- // edges: [{ from:$a, to:derived }, { from:derived, to:effect }]
637
- getReactiveFires() // → [{ id, ts }, …] (bounded, chronological)`,
638
- mistakes: [
639
- 'Holding the returned arrays expecting them to update — they are point-in-time snapshots; call again (the devtools panel polls)',
640
- 'Reading `node.value` for non-string state as the real value — it is a bounded, safely-stringified PREVIEW (never a raw ref — no pinning). Inspect the signal directly for the live value',
641
- 'Expecting fires for every write in a long-running app — `getReactiveFires()` is a fixed-size ring; older entries roll off',
642
- ],
643
- seeAlso: ['activateReactiveDevtools', 'getReactiveTrace', 'onSignalUpdate'],
644
- },
645
- ],
646
- gotchas: [
647
- {
648
- label: 'Signals are callable functions',
649
- note: 'Pyreon signals are NOT `.value` getters (Vue ref) or `[state, setState]` tuples (React useState). The signal IS the function: `count()` reads, `count.set(v)` writes, `count.update(fn)` derives. This is the #1 confusion for developers coming from other frameworks.',
650
- },
651
- {
652
- label: 'No dependency arrays',
653
- note: '`effect()` and `computed()` auto-track dependencies on each execution — no `[dep1, dep2]` array needed. Every signal read inside the body is a tracked dependency. This means conditional reads (`if (cond()) { return x() }`) only track `x` when `cond()` is true.',
654
- },
655
- {
656
- label: 'Standalone',
657
- note: '`@pyreon/reactivity` has zero dependencies. Use it in Node/Bun scripts, edge workers, or any JavaScript environment without pulling in the rest of the framework. `@pyreon/core` and `@pyreon/runtime-dom` build on it but are not required.',
658
- },
659
- ],
660
- })