@pyreon/reactivity 0.15.0 → 0.16.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 +4 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +145 -25
- package/lib/types/index.d.ts +82 -3
- package/package.json +1 -1
- package/src/batch.ts +21 -1
- package/src/createSelector.ts +44 -12
- package/src/index.ts +8 -2
- package/src/manifest.ts +372 -5
- package/src/reconcile.ts +9 -1
- package/src/resource.ts +19 -1
- package/src/scope.ts +38 -0
- package/src/signal.ts +26 -2
- package/src/store.ts +111 -11
- package/src/tests/batch.test.ts +187 -0
- package/src/tests/computed.test.ts +54 -0
- package/src/tests/createSelector.test.ts +59 -0
- package/src/tests/fanout-repro.test.ts +179 -0
- package/src/tests/manifest-snapshot.test.ts +17 -1
- package/src/tests/resource.test.ts +93 -0
- package/src/tests/scope.test.ts +29 -0
- package/src/tests/signal.test.ts +108 -0
- package/src/tests/store.test.ts +54 -0
- package/src/tests/vue-parity.test.ts +191 -0
package/src/manifest.ts
CHANGED
|
@@ -49,13 +49,17 @@ effect(() => {
|
|
|
49
49
|
features: [
|
|
50
50
|
'signal<T>() — callable function with .set() and .update()',
|
|
51
51
|
'computed<T>() — auto-tracked memoized derivation',
|
|
52
|
-
'effect() — side-
|
|
53
|
-
'batch()
|
|
52
|
+
'effect() / renderEffect() — side-effects with auto-tracking',
|
|
53
|
+
'batch() / nextTick() — write-grouping + flush awaiter',
|
|
54
54
|
'onCleanup() — register cleanup inside effects',
|
|
55
55
|
'watch(source, callback) — explicit reactive watcher',
|
|
56
|
-
'
|
|
57
|
-
'
|
|
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',
|
|
58
60
|
'untrack() — read without subscribing',
|
|
61
|
+
'onSignalUpdate() / inspectSignal() / why() — debug instrumentation',
|
|
62
|
+
'setErrorHandler() — global hook for unhandled effect errors',
|
|
59
63
|
'Standalone — zero DOM, zero JSX, zero framework dependency',
|
|
60
64
|
],
|
|
61
65
|
api: [
|
|
@@ -123,6 +127,26 @@ effect(() => {
|
|
|
123
127
|
],
|
|
124
128
|
seeAlso: ['onCleanup', 'computed', 'renderEffect'],
|
|
125
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
|
+
},
|
|
126
150
|
{
|
|
127
151
|
name: 'batch',
|
|
128
152
|
kind: 'function',
|
|
@@ -142,6 +166,23 @@ batch(() => {
|
|
|
142
166
|
],
|
|
143
167
|
seeAlso: ['signal', 'effect'],
|
|
144
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
|
+
},
|
|
145
186
|
{
|
|
146
187
|
name: 'onCleanup',
|
|
147
188
|
kind: 'function',
|
|
@@ -171,15 +212,68 @@ batch(() => {
|
|
|
171
212
|
mistakes: [
|
|
172
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',
|
|
173
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',
|
|
174
216
|
],
|
|
175
217
|
seeAlso: ['effect', 'computed'],
|
|
176
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
|
+
},
|
|
177
271
|
{
|
|
178
272
|
name: 'createStore',
|
|
179
273
|
kind: 'function',
|
|
180
274
|
signature: '<T extends object>(initial: T) => T',
|
|
181
275
|
summary:
|
|
182
|
-
'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 objects
|
|
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.',
|
|
183
277
|
example: `const store = createStore({
|
|
184
278
|
todos: [{ text: 'Learn Pyreon', done: false }],
|
|
185
279
|
filter: 'all',
|
|
@@ -190,9 +284,116 @@ store.todos.push({ text: 'Build app', done: false }) // array methods work`,
|
|
|
190
284
|
'Replacing the entire store object — `store = { ... }` replaces the variable, not the proxy. Mutate properties instead: `store.filter = "active"`',
|
|
191
285
|
'Destructuring store properties at setup — `const { filter } = store` captures the value once, losing reactivity. Read `store.filter` inside reactive scopes',
|
|
192
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',
|
|
193
288
|
],
|
|
194
289
|
seeAlso: ['signal'],
|
|
195
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
|
+
},
|
|
196
397
|
{
|
|
197
398
|
name: 'untrack',
|
|
198
399
|
kind: 'function',
|
|
@@ -208,6 +409,172 @@ store.todos.push({ text: 'Build app', done: false }) // array methods work`,
|
|
|
208
409
|
],
|
|
209
410
|
seeAlso: ['signal', 'effect'],
|
|
210
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: 'setErrorHandler',
|
|
558
|
+
kind: 'function',
|
|
559
|
+
signature: '(fn: (err: unknown) => void) => void',
|
|
560
|
+
summary:
|
|
561
|
+
'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.**',
|
|
562
|
+
example: `setErrorHandler(err => {
|
|
563
|
+
reportToSentry(err)
|
|
564
|
+
toast.error('Something went wrong')
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
effect(() => {
|
|
568
|
+
if (count() > 100) throw new Error('count too high')
|
|
569
|
+
})
|
|
570
|
+
count.set(101) // logs/reports via handler instead of crashing`,
|
|
571
|
+
mistakes: [
|
|
572
|
+
'Calling `setErrorHandler` multiple times and expecting all to fire — the second call REPLACES the first. Compose multiple handlers manually if you need a chain',
|
|
573
|
+
'Throwing inside the handler — the framework will swallow this too, but you lose visibility. Make handlers no-throw (try/catch internally if needed)',
|
|
574
|
+
'Expecting the handler to receive errors from `signal.set()` writes — only effect-runtime errors are routed. Synchronous errors at write time bubble up normally',
|
|
575
|
+
],
|
|
576
|
+
seeAlso: ['effect', 'renderEffect'],
|
|
577
|
+
},
|
|
211
578
|
],
|
|
212
579
|
gotchas: [
|
|
213
580
|
{
|
package/src/reconcile.ts
CHANGED
|
@@ -28,7 +28,15 @@ export function reconcile<T extends object>(source: T, target: T): void {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function _reconcileInner(source: object, target: object, seen: WeakSet<object>): void {
|
|
31
|
-
|
|
31
|
+
// The `seen` set is keyed on `source`, not `target` — protects against
|
|
32
|
+
// CIRCULAR references in the source tree (avoids infinite recursion). A
|
|
33
|
+
// consequence: DIAMOND-shaped sources (the SAME nested object referenced
|
|
34
|
+
// from two different parent paths in `source`) only get reconciled into
|
|
35
|
+
// their FIRST encountered position in `target`. The second occurrence is
|
|
36
|
+
// skipped. This is intentional — reconcile assumes source is a tree, not
|
|
37
|
+
// a DAG. Pass distinct object references (or deep-clone before reconcile)
|
|
38
|
+
// if your source is a DAG.
|
|
39
|
+
if (seen.has(source)) return
|
|
32
40
|
seen.add(source)
|
|
33
41
|
if (Array.isArray(source) && Array.isArray(target)) {
|
|
34
42
|
_reconcileArray(source as unknown[], target as unknown[], seen)
|
package/src/resource.ts
CHANGED
|
@@ -12,6 +12,13 @@ export interface Resource<T> {
|
|
|
12
12
|
error: Signal<unknown>
|
|
13
13
|
/** Re-run the fetcher with the current source value. */
|
|
14
14
|
refetch(): void
|
|
15
|
+
/**
|
|
16
|
+
* Stop the source-tracking effect. After dispose(), source changes no
|
|
17
|
+
* longer trigger fetches and any in-flight response is ignored. Idempotent.
|
|
18
|
+
* Required for resources created outside an `EffectScope` to avoid leaking
|
|
19
|
+
* the source-tracking effect for the lifetime of the program.
|
|
20
|
+
*/
|
|
21
|
+
dispose(): void
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
/**
|
|
@@ -50,7 +57,8 @@ export function createResource<T, P>(
|
|
|
50
57
|
})
|
|
51
58
|
}
|
|
52
59
|
|
|
53
|
-
|
|
60
|
+
let disposed = false
|
|
61
|
+
const sourceEffect = effect(() => {
|
|
54
62
|
const param = source()
|
|
55
63
|
runUntracked(() => doFetch(param))
|
|
56
64
|
})
|
|
@@ -60,7 +68,17 @@ export function createResource<T, P>(
|
|
|
60
68
|
loading,
|
|
61
69
|
error,
|
|
62
70
|
refetch() {
|
|
71
|
+
if (disposed) return
|
|
63
72
|
runUntracked(() => doFetch(source()))
|
|
64
73
|
},
|
|
74
|
+
dispose() {
|
|
75
|
+
if (disposed) return
|
|
76
|
+
disposed = true
|
|
77
|
+
// Bump requestId so any pending in-flight response is treated as stale
|
|
78
|
+
// and discarded by the .then/.catch handlers — prevents post-dispose
|
|
79
|
+
// writes to data/loading/error.
|
|
80
|
+
requestId++
|
|
81
|
+
sourceEffect.dispose()
|
|
82
|
+
},
|
|
65
83
|
}
|
|
66
84
|
}
|
package/src/scope.ts
CHANGED
|
@@ -32,6 +32,12 @@ export class EffectScope {
|
|
|
32
32
|
|
|
33
33
|
/** Register a callback to run after any reactive update in this scope. */
|
|
34
34
|
addUpdateHook(fn: () => void): void {
|
|
35
|
+
// Mirror `add()`'s behavior: silently no-op when scope is stopped.
|
|
36
|
+
// Without this, hooks pushed after `stop()` would leak into a freshly-
|
|
37
|
+
// allocated `_updateHooks` array and never fire (because `notifyEffectRan`
|
|
38
|
+
// checks `_active` first), giving the caller no feedback that the
|
|
39
|
+
// registration was futile.
|
|
40
|
+
if (!this._active) return
|
|
35
41
|
if (this._updateHooks === null) this._updateHooks = []
|
|
36
42
|
this._updateHooks.push(fn)
|
|
37
43
|
}
|
|
@@ -83,3 +89,35 @@ export function setCurrentScope(scope: EffectScope | null): void {
|
|
|
83
89
|
export function effectScope(): EffectScope {
|
|
84
90
|
return new EffectScope()
|
|
85
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Register a callback to run when the current `EffectScope` stops. Vue 3
|
|
95
|
+
* parity. Must be called inside `scope.runInScope(fn)` — the registration
|
|
96
|
+
* captures the ambient scope, so calling outside any scope is a no-op (with
|
|
97
|
+
* a dev warning to surface the missing scope).
|
|
98
|
+
*
|
|
99
|
+
* Use to clean up resources tied to a scope's lifetime: timers, listeners,
|
|
100
|
+
* external subscriptions. Equivalent to calling `getCurrentScope()?.add({
|
|
101
|
+
* dispose: fn })` but with the scope capture handled.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* scope.runInScope(() => {
|
|
105
|
+
* const ws = new WebSocket(url)
|
|
106
|
+
* onScopeDispose(() => ws.close())
|
|
107
|
+
* // ws.close() runs when scope.stop() is called
|
|
108
|
+
* })
|
|
109
|
+
*/
|
|
110
|
+
export function onScopeDispose(fn: () => void): void {
|
|
111
|
+
const scope = _currentScope
|
|
112
|
+
if (!scope) {
|
|
113
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
114
|
+
// oxlint-disable-next-line no-console
|
|
115
|
+
console.warn(
|
|
116
|
+
'[pyreon] onScopeDispose() called without an active EffectScope — callback will never run. ' +
|
|
117
|
+
'Wrap the call in `scope.runInScope(() => { ... })` or check `getCurrentScope()` before calling.',
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
scope.add({ dispose: fn })
|
|
123
|
+
}
|
package/src/signal.ts
CHANGED
|
@@ -40,7 +40,11 @@ export interface Signal<T> {
|
|
|
40
40
|
* Returns a disposer that nulls the slot.
|
|
41
41
|
*/
|
|
42
42
|
direct(updater: () => void): () => void
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Debug name — useful for devtools and logging. Set via the `name` option at
|
|
45
|
+
* creation; can be reassigned at any time (`s.label = 'renamed'`) since it's
|
|
46
|
+
* stored as a regular own property on the signal function.
|
|
47
|
+
*/
|
|
44
48
|
label: string | undefined
|
|
45
49
|
/** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
|
|
46
50
|
debug(): SignalDebugInfo<T>
|
|
@@ -83,7 +87,27 @@ function _set(this: SignalFn<unknown>, newValue: unknown) {
|
|
|
83
87
|
_countSink.__pyreon_count__?.('reactivity.signalWrite')
|
|
84
88
|
const prev = this._v
|
|
85
89
|
this._v = newValue
|
|
86
|
-
if (isTracing())
|
|
90
|
+
if (isTracing()) {
|
|
91
|
+
// Trace listeners are user-supplied debug code that fires on every
|
|
92
|
+
// signal write. A throwing listener here would leave `_v` updated but
|
|
93
|
+
// subscribers never notified (state divergence: readers see the new
|
|
94
|
+
// value, but no effects run). Trace failures must not corrupt program
|
|
95
|
+
// state — wrap in try/catch and route through `_userErrorHandler` so
|
|
96
|
+
// the corruption is at least visible. Listeners are removed via the
|
|
97
|
+
// disposer returned by `onSignalUpdate`; this catch prevents one bad
|
|
98
|
+
// listener from breaking unrelated reactive flow.
|
|
99
|
+
try {
|
|
100
|
+
_notifyTraceListeners(this as unknown as Signal<unknown>, prev, newValue)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
103
|
+
// oxlint-disable-next-line no-console
|
|
104
|
+
console.error(
|
|
105
|
+
'[pyreon] signal trace listener threw — listener is buggy. Subscribers continue uninterrupted.',
|
|
106
|
+
err,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
87
111
|
// Auto-batch the notification chain. Without this, a diamond dependency
|
|
88
112
|
// graph (a → b, c → d → effect) fires the apex effect TWICE per write
|
|
89
113
|
// because subscribers cascade inline: the first path through `b` reaches
|