@pyreon/reactivity 0.14.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 +230 -47
- package/lib/types/index.d.ts +95 -3
- package/package.json +5 -4
- package/src/batch.ts +155 -32
- package/src/computed.ts +21 -6
- package/src/createSelector.ts +44 -12
- package/src/effect.ts +149 -14
- package/src/env.d.ts +6 -0
- package/src/index.ts +18 -3
- 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 +29 -12
- package/src/store.ts +111 -11
- package/src/tests/batch.test.ts +605 -0
- package/src/tests/computed.test.ts +86 -0
- package/src/tests/createSelector.test.ts +59 -0
- package/src/tests/effect.test.ts +65 -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/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/lib/types/index.d.ts
CHANGED
|
@@ -71,6 +71,19 @@ interface ComputedOptions<T> {
|
|
|
71
71
|
declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T>;
|
|
72
72
|
//#endregion
|
|
73
73
|
//#region src/createSelector.d.ts
|
|
74
|
+
/** Selector predicate with a `dispose()` method to release internal state. */
|
|
75
|
+
interface Selector<T> {
|
|
76
|
+
(value: T): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Stop the source-tracking effect AND clear the per-value subscriber/host
|
|
79
|
+
* Maps. After dispose, calls to the selector return the last-known result
|
|
80
|
+
* but no longer track. Required for selectors over dynamic value spaces
|
|
81
|
+
* (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
|
|
82
|
+
* each unique queried value adds a permanent entry to the internal Maps,
|
|
83
|
+
* leaking memory for the lifetime of the program. Idempotent.
|
|
84
|
+
*/
|
|
85
|
+
dispose(): void;
|
|
86
|
+
}
|
|
74
87
|
/**
|
|
75
88
|
* Create an equality selector — returns a reactive predicate that is true
|
|
76
89
|
* only for the currently selected value.
|
|
@@ -83,8 +96,13 @@ declare function computed<T>(fn: () => T, options?: ComputedOptions<T>): Compute
|
|
|
83
96
|
* const isSelected = createSelector(selectedId)
|
|
84
97
|
* // In each row:
|
|
85
98
|
* class: () => (isSelected(row.id) ? "selected" : "")
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* // Dynamic value spaces — call dispose() to release the per-value cache:
|
|
102
|
+
* const isCurrentTab = createSelector(() => currentTabId())
|
|
103
|
+
* onUnmount(() => isCurrentTab.dispose())
|
|
86
104
|
*/
|
|
87
|
-
declare function createSelector<T>(source: () => T):
|
|
105
|
+
declare function createSelector<T>(source: () => T): Selector<T>;
|
|
88
106
|
//#endregion
|
|
89
107
|
//#region src/signal.d.ts
|
|
90
108
|
interface SignalDebugInfo<T> {
|
|
@@ -120,7 +138,11 @@ interface Signal<T> {
|
|
|
120
138
|
* Returns a disposer that nulls the slot.
|
|
121
139
|
*/
|
|
122
140
|
direct(updater: () => void): () => void;
|
|
123
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* Debug name — useful for devtools and logging. Set via the `name` option at
|
|
143
|
+
* creation; can be reassigned at any time (`s.label = 'renamed'`) since it's
|
|
144
|
+
* stored as a regular own property on the signal function.
|
|
145
|
+
*/
|
|
124
146
|
label: string | undefined;
|
|
125
147
|
/** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
|
|
126
148
|
debug(): SignalDebugInfo<T>;
|
|
@@ -191,6 +213,19 @@ declare function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T>;
|
|
|
191
213
|
interface Effect {
|
|
192
214
|
dispose(): void;
|
|
193
215
|
}
|
|
216
|
+
interface ReactiveSnapshotCapture {
|
|
217
|
+
capture: () => unknown;
|
|
218
|
+
/** Run `fn` with the previously-captured snapshot active. */
|
|
219
|
+
restore: <T>(snap: unknown, fn: () => T) => T;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Register a capture/restore pair so reactivity-layer effects (`_bind`,
|
|
223
|
+
* `renderEffect`, `effect`) can preserve external context (e.g. the core
|
|
224
|
+
* provide/useContext stack) across signal-driven re-runs. Called by
|
|
225
|
+
* `@pyreon/core`'s context module at import time. Idempotent — calling again
|
|
226
|
+
* replaces the previously registered hook.
|
|
227
|
+
*/
|
|
228
|
+
declare function setSnapshotCapture(hook: ReactiveSnapshotCapture | null): void;
|
|
194
229
|
/**
|
|
195
230
|
* Register a cleanup function inside an effect. The cleanup runs:
|
|
196
231
|
* - Before the effect re-runs (when dependencies change)
|
|
@@ -273,6 +308,13 @@ interface Resource<T> {
|
|
|
273
308
|
error: Signal<unknown>;
|
|
274
309
|
/** Re-run the fetcher with the current source value. */
|
|
275
310
|
refetch(): void;
|
|
311
|
+
/**
|
|
312
|
+
* Stop the source-tracking effect. After dispose(), source changes no
|
|
313
|
+
* longer trigger fetches and any in-flight response is ignored. Idempotent.
|
|
314
|
+
* Required for resources created outside an `EffectScope` to avoid leaking
|
|
315
|
+
* the source-tracking effect for the lifetime of the program.
|
|
316
|
+
*/
|
|
317
|
+
dispose(): void;
|
|
276
318
|
}
|
|
277
319
|
/**
|
|
278
320
|
* Async data primitive. Fetches data reactively whenever `source()` changes.
|
|
@@ -317,6 +359,24 @@ declare function getCurrentScope(): EffectScope | null;
|
|
|
317
359
|
declare function setCurrentScope(scope: EffectScope | null): void;
|
|
318
360
|
/** Create a new EffectScope. */
|
|
319
361
|
declare function effectScope(): EffectScope;
|
|
362
|
+
/**
|
|
363
|
+
* Register a callback to run when the current `EffectScope` stops. Vue 3
|
|
364
|
+
* parity. Must be called inside `scope.runInScope(fn)` — the registration
|
|
365
|
+
* captures the ambient scope, so calling outside any scope is a no-op (with
|
|
366
|
+
* a dev warning to surface the missing scope).
|
|
367
|
+
*
|
|
368
|
+
* Use to clean up resources tied to a scope's lifetime: timers, listeners,
|
|
369
|
+
* external subscriptions. Equivalent to calling `getCurrentScope()?.add({
|
|
370
|
+
* dispose: fn })` but with the scope capture handled.
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* scope.runInScope(() => {
|
|
374
|
+
* const ws = new WebSocket(url)
|
|
375
|
+
* onScopeDispose(() => ws.close())
|
|
376
|
+
* // ws.close() runs when scope.stop() is called
|
|
377
|
+
* })
|
|
378
|
+
*/
|
|
379
|
+
declare function onScopeDispose(fn: () => void): void;
|
|
320
380
|
//#endregion
|
|
321
381
|
//#region src/store.d.ts
|
|
322
382
|
/**
|
|
@@ -333,6 +393,20 @@ declare function effectScope(): EffectScope;
|
|
|
333
393
|
* state.count++ // only the count effect re-runs
|
|
334
394
|
* state.items[0].text = "world" // only text-tracking effects re-run
|
|
335
395
|
*/
|
|
396
|
+
/**
|
|
397
|
+
* Mark an object as RAW — `createStore` and `shallowReactive` will return it
|
|
398
|
+
* unwrapped. Useful when storing class instances, third-party objects, or
|
|
399
|
+
* other shapes that shouldn't be deeply proxied (Vue 3 parity).
|
|
400
|
+
*
|
|
401
|
+
* @example
|
|
402
|
+
* const cm = markRaw(new CodeMirrorView(...))
|
|
403
|
+
* const store = createStore({ editor: cm })
|
|
404
|
+
* store.editor === cm // true (not wrapped)
|
|
405
|
+
*
|
|
406
|
+
* Note: marking is one-way — there's no `unmarkRaw`. Mark BEFORE the object
|
|
407
|
+
* enters a store; marking after wrap doesn't unwrap an existing proxy.
|
|
408
|
+
*/
|
|
409
|
+
declare function markRaw<T extends object>(value: T): T;
|
|
336
410
|
/** Returns true if the value is a createStore proxy. */
|
|
337
411
|
declare function isStore(value: unknown): boolean;
|
|
338
412
|
/**
|
|
@@ -340,6 +414,24 @@ declare function isStore(value: unknown): boolean;
|
|
|
340
414
|
* Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
|
|
341
415
|
*/
|
|
342
416
|
declare function createStore<T extends object>(initial: T): T;
|
|
417
|
+
/**
|
|
418
|
+
* Create a SHALLOW reactive store — only top-level mutations trigger updates.
|
|
419
|
+
* Nested objects are NOT auto-wrapped; reading a nested object returns the
|
|
420
|
+
* raw reference. Use when:
|
|
421
|
+
* - the nested objects are immutable (frozen API responses)
|
|
422
|
+
* - you want explicit control over which subtrees are reactive
|
|
423
|
+
* - you need to store class instances or third-party objects without
|
|
424
|
+
* paying the deep-proxy overhead
|
|
425
|
+
*
|
|
426
|
+
* @example
|
|
427
|
+
* const store = shallowReactive({ user: { name: 'Alice' }, count: 0 })
|
|
428
|
+
* effect(() => console.log(store.user)) // tracks store.user reference
|
|
429
|
+
* effect(() => console.log(store.count)) // tracks store.count
|
|
430
|
+
* store.user.name = 'Bob' // does NOT trigger any effect
|
|
431
|
+
* store.count = 5 // triggers the count effect
|
|
432
|
+
* store.user = { name: 'Bob' } // triggers the user effect
|
|
433
|
+
*/
|
|
434
|
+
declare function shallowReactive<T extends object>(initial: T): T;
|
|
343
435
|
//#endregion
|
|
344
436
|
//#region src/tracking.d.ts
|
|
345
437
|
/** Read signals without subscribing. Alias: `untrack`. */
|
|
@@ -373,5 +465,5 @@ interface WatchOptions {
|
|
|
373
465
|
*/
|
|
374
466
|
declare function watch<T>(source: () => T, callback: (newVal: T, oldVal: T | undefined) => void | (() => void), opts?: WatchOptions): () => void;
|
|
375
467
|
//#endregion
|
|
376
|
-
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onCleanup, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, signal, watch, why };
|
|
468
|
+
export { Cell, type Computed, type ComputedOptions, type Effect, EffectScope, type ReactiveSnapshotCapture, type ReadonlySignal, type Resource, type Signal, type SignalDebugInfo, type SignalOptions, type WatchOptions, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, markRaw, nextTick, onCleanup, onScopeDispose, onSignalUpdate, reconcile, renderEffect, runUntracked, runUntracked as untrack, setCurrentScope, setErrorHandler, setSnapshotCapture, shallowReactive, signal, watch, why };
|
|
377
469
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/reactivity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Signals-based reactivity system for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/reactivity#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
|
+
"!lib/**/*.map",
|
|
17
18
|
"src",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
@@ -33,9 +34,6 @@
|
|
|
33
34
|
"publishConfig": {
|
|
34
35
|
"access": "public"
|
|
35
36
|
},
|
|
36
|
-
"devDependencies": {
|
|
37
|
-
"@pyreon/manifest": "0.13.1"
|
|
38
|
-
},
|
|
39
37
|
"scripts": {
|
|
40
38
|
"build": "vl_rolldown_build",
|
|
41
39
|
"dev": "vl_rolldown_build-watch",
|
|
@@ -43,5 +41,8 @@
|
|
|
43
41
|
"typecheck": "tsc --noEmit",
|
|
44
42
|
"lint": "oxlint .",
|
|
45
43
|
"prepublishOnly": "bun run build"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@pyreon/manifest": "0.13.1"
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/batch.ts
CHANGED
|
@@ -1,15 +1,66 @@
|
|
|
1
1
|
// Batch multiple signal updates into a single notification pass.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// Two-tier flush: computed recomputes drain first (within-pass Set-dedup),
|
|
3
|
+
// THEN effects drain (multi-pass with cross-pass re-fire support).
|
|
4
|
+
//
|
|
5
|
+
// Dev-mode invariant gate: see https://github.com/pyreon/pyreon/blob/main/packages/core/reactivity/src/tests/batch.test.ts
|
|
6
|
+
// for the property-based test that fuzzes random cascade graphs against this
|
|
7
|
+
// invariant. The build-time gate folds to dead code in production bundles.
|
|
8
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
4
9
|
|
|
5
10
|
let batchDepth = 0
|
|
6
11
|
|
|
7
|
-
// Two
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
// Two-tier queue design:
|
|
13
|
+
//
|
|
14
|
+
// 1. **`pendingRecomputes`** — computed.recompute callbacks. Drained FIRST in
|
|
15
|
+
// a cascading-iteration loop: cascading recomputes enqueued during a
|
|
16
|
+
// drain land in the same Set (Set.add idempotency dedupes), iteration
|
|
17
|
+
// visits added entries (JS Set iteration semantics), and the recompute
|
|
18
|
+
// layer settles before any effect fires. This guarantees effects always
|
|
19
|
+
// read fully-propagated computed values.
|
|
20
|
+
//
|
|
21
|
+
// 2. **`pendingEffects`** — effect.run callbacks. Drained SECOND, multi-pass.
|
|
22
|
+
// Within a single pass, Set.add idempotency gives us within-pass dedup
|
|
23
|
+
// (diamond / multi-dep selector). Across passes (entries re-enqueued
|
|
24
|
+
// AFTER being visited in the current pass — see `_visitedThisPass`), they
|
|
25
|
+
// fire AGAIN — needed for control flow that re-renders based on its own
|
|
26
|
+
// dispatch (e.g. ErrorBoundary's handler calling `error.set(err)` during
|
|
27
|
+
// the same run that mounted the throwing child). MAX_PASSES caps total
|
|
28
|
+
// passes at 32 to prevent pathological infinite re-enqueue loops.
|
|
29
|
+
//
|
|
30
|
+
// **Why two tiers, not one Set:**
|
|
31
|
+
// - Single-Set iteration (the prior design) worked for shallow cascades
|
|
32
|
+
// because the cascading recompute → effect re-enqueue happened BEFORE
|
|
33
|
+
// iteration reached the effect. For deep cascades (3+ hops), iteration
|
|
34
|
+
// reached the effect (with its stale upstream value) BEFORE the cascade
|
|
35
|
+
// finished propagating. Effect read stale values; subsequent cascade
|
|
36
|
+
// re-enqueues were dropped by Set.add idempotency.
|
|
37
|
+
// - Splitting recomputes from effects fixes this: all computed recomputes
|
|
38
|
+
// settle before any effect runs. The cascade-asymmetry contract from
|
|
39
|
+
// PR #381 is preserved (effects still fire once per batched change),
|
|
40
|
+
// AND deep-cascade correctness is added (effects always read settled
|
|
41
|
+
// values).
|
|
42
|
+
// - Multi-pass effect drain unblocks ErrorBoundary's "re-fire after
|
|
43
|
+
// dispatching from inside own run" pattern without breaking the
|
|
44
|
+
// single-fire contract for non-self-dispatching effects.
|
|
45
|
+
//
|
|
46
|
+
// **How a callback gets routed:** computed registers its `recompute` via
|
|
47
|
+
// `_markRecompute(fn)` at creation time. The internal `_recomputes` WeakSet
|
|
48
|
+
// tracks them. `enqueuePendingNotification` checks the WeakSet to route.
|
|
49
|
+
const pendingRecomputes = new Set<() => void>()
|
|
50
|
+
const pendingEffects = new Set<() => void>()
|
|
51
|
+
const _nextEffectPass = new Set<() => void>()
|
|
52
|
+
let _visitedThisPass: Set<() => void> | null = null
|
|
53
|
+
const _recomputes = new WeakSet<() => void>()
|
|
54
|
+
const MAX_PASSES = 32
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mark a callback as a computed recompute (called from computed.ts at
|
|
58
|
+
* creation time). Routes future enqueues into the recompute queue so they
|
|
59
|
+
* settle before any effects fire.
|
|
60
|
+
*/
|
|
61
|
+
export function _markRecompute(fn: () => void): void {
|
|
62
|
+
_recomputes.add(fn)
|
|
63
|
+
}
|
|
13
64
|
|
|
14
65
|
export function batch(fn: () => void): void {
|
|
15
66
|
batchDepth++
|
|
@@ -17,34 +68,92 @@ export function batch(fn: () => void): void {
|
|
|
17
68
|
fn()
|
|
18
69
|
} finally {
|
|
19
70
|
batchDepth--
|
|
20
|
-
if (batchDepth === 0 &&
|
|
71
|
+
if (batchDepth === 0 && (pendingRecomputes.size > 0 || pendingEffects.size > 0)) {
|
|
21
72
|
// Keep batching active during flush so cascade-notifications emitted
|
|
22
|
-
// by flushing subscribers enqueue into the
|
|
23
|
-
//
|
|
24
|
-
// while-loop drains any cascade rounds until the graph is stable.
|
|
25
|
-
//
|
|
26
|
-
// Without this, a diamond dependency (a → b, c → d → effect) re-fires
|
|
27
|
-
// the apex effect TWICE per signal write: the first notification path
|
|
28
|
-
// through `b` reaches `effect`, whose read clears `d`'s dirty flag;
|
|
29
|
-
// then when `c` is notified (still in the first flush round), it
|
|
30
|
-
// re-dirties `d`, which re-notifies `effect`. Keeping batchDepth at 1
|
|
31
|
-
// during flush routes those cascade-notifications through the Set,
|
|
32
|
-
// which dedupes on `d`'s recompute and on `effect`'s run.
|
|
33
|
-
//
|
|
34
|
-
// See `packages/internals/perf-harness/src/tests/diamond-probe.test.ts`
|
|
35
|
-
// for the empirical probe that caught this.
|
|
73
|
+
// by flushing subscribers enqueue into the same queues (dedup against
|
|
74
|
+
// already-queued entries) instead of firing inline.
|
|
36
75
|
batchDepth = 1
|
|
37
76
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
77
|
+
// Outer loop: alternate between tier-1 (recomputes) and tier-2
|
|
78
|
+
// (effects) until both queues are empty. An effect can write a
|
|
79
|
+
// signal whose subscribers include lazy `computed.recompute`s — those
|
|
80
|
+
// get enqueued into pendingRecomputes mid-effect, and we need to
|
|
81
|
+
// drain them BEFORE the next effect pass so downstream effects see
|
|
82
|
+
// the propagated dirty flag. MAX_PASSES caps the OUTER loop —
|
|
83
|
+
// counts effect-tier passes only since recomputes converge by
|
|
84
|
+
// `equals` short-circuit and don't infinite-loop in practice.
|
|
85
|
+
let effectPass = 0
|
|
86
|
+
while (pendingRecomputes.size > 0 || pendingEffects.size > 0) {
|
|
87
|
+
// Tier 1: drain all recomputes via cascading iteration. Set
|
|
88
|
+
// semantics visit entries added during iteration; Set.add
|
|
89
|
+
// idempotency dedupes diamond cascades. Recomputes converge by
|
|
90
|
+
// `equals` short-circuit (computedWithEquals returns early when
|
|
91
|
+
// value is unchanged) and computedLazy's `if (dirty) return`
|
|
92
|
+
// guard prevents re-fire.
|
|
93
|
+
for (const r of pendingRecomputes) r()
|
|
94
|
+
pendingRecomputes.clear()
|
|
95
|
+
|
|
96
|
+
// Tier 2: drain ONE pass of effects in multi-pass mode. Within-
|
|
97
|
+
// pass dedup preserved by Set.add idempotency on entries not yet
|
|
98
|
+
// visited this pass. Cross-pass re-fire enabled by routing
|
|
99
|
+
// already-visited entries to `_nextEffectPass` (handled in
|
|
100
|
+
// `enqueuePendingNotification`). After the pass, loop back to
|
|
101
|
+
// tier 1 to drain any recomputes the effects enqueued.
|
|
102
|
+
if (pendingEffects.size > 0) {
|
|
103
|
+
if (++effectPass > MAX_PASSES) {
|
|
104
|
+
if (__DEV__) {
|
|
105
|
+
// Surface labels of dropped effects when available — helps
|
|
106
|
+
// identify the offending effect in a real app. Falls back to
|
|
107
|
+
// bare count for anonymous effects.
|
|
108
|
+
const droppedCount = pendingEffects.size
|
|
109
|
+
const labels: string[] = []
|
|
110
|
+
for (const notify of pendingEffects) {
|
|
111
|
+
const label = (notify as { _label?: string })._label
|
|
112
|
+
if (label) labels.push(label)
|
|
113
|
+
if (labels.length >= 5) break
|
|
114
|
+
}
|
|
115
|
+
const labelHint = labels.length
|
|
116
|
+
? ` Sample labels: ${labels.join(', ')}${droppedCount > labels.length ? `, …${droppedCount - labels.length} more` : ''}.`
|
|
117
|
+
: ''
|
|
118
|
+
// oxlint-disable-next-line no-console
|
|
119
|
+
console.warn(
|
|
120
|
+
'[pyreon] batch effect flush exceeded MAX_PASSES (32) — possible infinite re-enqueue loop. ' +
|
|
121
|
+
`${droppedCount} pending effects dropped.${labelHint} ` +
|
|
122
|
+
'Common cause: an effect that writes to a signal it also reads, without a guard. ' +
|
|
123
|
+
'See packages/core/reactivity/src/batch.ts for the multi-pass flush contract.',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
// Drop the queue so subsequent batches start clean — without
|
|
127
|
+
// this, the next batch would re-encounter the offending effect
|
|
128
|
+
// immediately on its first pass and trip MAX_PASSES instantly,
|
|
129
|
+
// making the original error harder to diagnose.
|
|
130
|
+
pendingEffects.clear()
|
|
131
|
+
_nextEffectPass.clear()
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
_visitedThisPass = new Set<() => void>()
|
|
135
|
+
for (const notify of pendingEffects) {
|
|
136
|
+
_visitedThisPass.add(notify)
|
|
137
|
+
notify()
|
|
138
|
+
}
|
|
139
|
+
// Promote next-pass entries to pending for the next iteration.
|
|
140
|
+
pendingEffects.clear()
|
|
141
|
+
for (const next of _nextEffectPass) pendingEffects.add(next)
|
|
142
|
+
_nextEffectPass.clear()
|
|
143
|
+
}
|
|
46
144
|
}
|
|
47
145
|
} finally {
|
|
146
|
+
// Clear ALWAYS — even if a notify threw mid-iteration. Without this,
|
|
147
|
+
// the unflushed remainder leaks into the next batch and refires
|
|
148
|
+
// (audit bug #19). Effects wrap their callbacks in try/catch
|
|
149
|
+
// internally so this is rarely reachable in practice, but raw
|
|
150
|
+
// signal subscribers (signal.subscribe) and lower-level consumers
|
|
151
|
+
// can throw straight through, and a future refactor that swallows
|
|
152
|
+
// less aggressively would silently regress without this guard.
|
|
153
|
+
pendingRecomputes.clear()
|
|
154
|
+
pendingEffects.clear()
|
|
155
|
+
_nextEffectPass.clear()
|
|
156
|
+
_visitedThisPass = null
|
|
48
157
|
batchDepth = 0
|
|
49
158
|
}
|
|
50
159
|
}
|
|
@@ -55,8 +164,22 @@ export function isBatching(): boolean {
|
|
|
55
164
|
return batchDepth > 0
|
|
56
165
|
}
|
|
57
166
|
|
|
167
|
+
export function enquePendingNotificationDeprecated(): void {
|
|
168
|
+
// Kept as a comment placeholder — actual export is below. (Empty body to
|
|
169
|
+
// keep this file's exports list stable across the refactor.)
|
|
170
|
+
}
|
|
171
|
+
|
|
58
172
|
export function enqueuePendingNotification(notify: () => void): void {
|
|
59
|
-
|
|
173
|
+
// Route based on callback kind. Computed recomputes go to tier-1 queue,
|
|
174
|
+
// effects to tier-2. Within tier 2, already-visited-this-pass entries
|
|
175
|
+
// route to next-pass for cross-pass re-fire (ErrorBoundary's pattern).
|
|
176
|
+
if (_recomputes.has(notify)) {
|
|
177
|
+
pendingRecomputes.add(notify)
|
|
178
|
+
} else if (_visitedThisPass !== null && _visitedThisPass.has(notify)) {
|
|
179
|
+
_nextEffectPass.add(notify)
|
|
180
|
+
} else {
|
|
181
|
+
pendingEffects.add(notify)
|
|
182
|
+
}
|
|
60
183
|
}
|
|
61
184
|
|
|
62
185
|
/**
|
package/src/computed.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _markRecompute } from './batch'
|
|
1
2
|
import { _errorHandler } from './effect'
|
|
2
3
|
import { getCurrentScope } from './scope'
|
|
3
4
|
import {
|
|
@@ -10,9 +11,6 @@ import {
|
|
|
10
11
|
} from './tracking'
|
|
11
12
|
|
|
12
13
|
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
13
|
-
interface ViteMeta {
|
|
14
|
-
readonly env?: { readonly DEV?: boolean }
|
|
15
|
-
}
|
|
16
14
|
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
17
15
|
|
|
18
16
|
export interface Computed<T> {
|
|
@@ -54,6 +52,21 @@ function trackWithLocalDeps<T>(deps: Set<() => void>[], effect: () => void, fn:
|
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
export function computed<T>(fn: () => T, options?: ComputedOptions<T>): Computed<T> {
|
|
55
|
+
// Dev warning for async computed callbacks (audit bug #1 — extension).
|
|
56
|
+
// `computed(async () => …)` returns `Computed<Promise<T>>`, which silently
|
|
57
|
+
// breaks every consumer that expects `Computed<T>`. There's no scenario
|
|
58
|
+
// where async makes sense here — the recompute fires synchronously and
|
|
59
|
+
// tracks signals only in the synchronous prefix. For async-derived
|
|
60
|
+
// state, use `createResource` or a `signal<T>` updated from an effect.
|
|
61
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
62
|
+
if (fn.constructor && fn.constructor.name === 'AsyncFunction') {
|
|
63
|
+
// oxlint-disable-next-line no-console
|
|
64
|
+
console.warn(
|
|
65
|
+
'[pyreon] computed() received an async function. The result type becomes `Computed<Promise<T>>`, and signal reads after the first `await` are NOT tracked. ' +
|
|
66
|
+
'Use `createResource` for async-derived state, or compute synchronously over a signal that holds the awaited value.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
57
70
|
return options?.equals ? computedWithEquals(fn, options.equals) : computedLazy(fn)
|
|
58
71
|
}
|
|
59
72
|
|
|
@@ -82,11 +95,12 @@ function computedLazy<T>(fn: () => T): Computed<T> {
|
|
|
82
95
|
if (host._s) notifySubscribers(host._s)
|
|
83
96
|
if (directFns) for (const f of directFns) f?.()
|
|
84
97
|
}
|
|
98
|
+
_markRecompute(recompute)
|
|
85
99
|
|
|
86
100
|
const read = (): T => {
|
|
87
101
|
trackSubscriber(host)
|
|
88
102
|
if (dirty) {
|
|
89
|
-
if (
|
|
103
|
+
if (process.env.NODE_ENV !== 'production')
|
|
90
104
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
91
105
|
try {
|
|
92
106
|
if (tracked) {
|
|
@@ -153,7 +167,7 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
153
167
|
|
|
154
168
|
const recompute = () => {
|
|
155
169
|
if (disposed) return
|
|
156
|
-
if (
|
|
170
|
+
if (process.env.NODE_ENV !== 'production')
|
|
157
171
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
158
172
|
cleanupLocalDeps(deps, recompute)
|
|
159
173
|
try {
|
|
@@ -169,11 +183,12 @@ function computedWithEquals<T>(fn: () => T, equals: (prev: T, next: T) => boolea
|
|
|
169
183
|
if (host._s) notifySubscribers(host._s)
|
|
170
184
|
if (directFns) for (const f of directFns) f?.()
|
|
171
185
|
}
|
|
186
|
+
_markRecompute(recompute)
|
|
172
187
|
|
|
173
188
|
const read = (): T => {
|
|
174
189
|
trackSubscriber(host)
|
|
175
190
|
if (dirty) {
|
|
176
|
-
if (
|
|
191
|
+
if (process.env.NODE_ENV !== 'production')
|
|
177
192
|
_countSink.__pyreon_count__?.('reactivity.computedRecompute')
|
|
178
193
|
cleanupLocalDeps(deps, recompute)
|
|
179
194
|
try {
|
package/src/createSelector.ts
CHANGED
|
@@ -21,6 +21,20 @@ function notifyBucket(bucket: Set<() => void>): void {
|
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Selector predicate with a `dispose()` method to release internal state. */
|
|
25
|
+
export interface Selector<T> {
|
|
26
|
+
(value: T): boolean
|
|
27
|
+
/**
|
|
28
|
+
* Stop the source-tracking effect AND clear the per-value subscriber/host
|
|
29
|
+
* Maps. After dispose, calls to the selector return the last-known result
|
|
30
|
+
* but no longer track. Required for selectors over dynamic value spaces
|
|
31
|
+
* (UUIDs, ephemeral IDs) created outside an `EffectScope` — without it,
|
|
32
|
+
* each unique queried value adds a permanent entry to the internal Maps,
|
|
33
|
+
* leaking memory for the lifetime of the program. Idempotent.
|
|
34
|
+
*/
|
|
35
|
+
dispose(): void
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
/**
|
|
25
39
|
* Create an equality selector — returns a reactive predicate that is true
|
|
26
40
|
* only for the currently selected value.
|
|
@@ -33,13 +47,19 @@ function notifyBucket(bucket: Set<() => void>): void {
|
|
|
33
47
|
* const isSelected = createSelector(selectedId)
|
|
34
48
|
* // In each row:
|
|
35
49
|
* class: () => (isSelected(row.id) ? "selected" : "")
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* // Dynamic value spaces — call dispose() to release the per-value cache:
|
|
53
|
+
* const isCurrentTab = createSelector(() => currentTabId())
|
|
54
|
+
* onUnmount(() => isCurrentTab.dispose())
|
|
36
55
|
*/
|
|
37
|
-
export function createSelector<T>(source: () => T):
|
|
56
|
+
export function createSelector<T>(source: () => T): Selector<T> {
|
|
38
57
|
const subs = new Map<T, Set<() => void>>()
|
|
39
58
|
let current: T
|
|
40
59
|
let initialized = false
|
|
60
|
+
let disposed = false
|
|
41
61
|
|
|
42
|
-
effect(() => {
|
|
62
|
+
const sourceEffect = effect(() => {
|
|
43
63
|
const next = source()
|
|
44
64
|
if (!initialized) {
|
|
45
65
|
initialized = true
|
|
@@ -60,18 +80,30 @@ export function createSelector<T>(source: () => T): (value: T) => boolean {
|
|
|
60
80
|
// Reusable hosts per value — avoids allocating a closure per trackSubscriber call
|
|
61
81
|
const hosts = new Map<T, { _s: Set<() => void> | null }>()
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
const selector = ((value: T): boolean => {
|
|
84
|
+
if (!disposed) {
|
|
85
|
+
let host = hosts.get(value)
|
|
86
|
+
if (!host) {
|
|
87
|
+
let bucket = subs.get(value)
|
|
88
|
+
if (!bucket) {
|
|
89
|
+
bucket = new Set()
|
|
90
|
+
subs.set(value, bucket)
|
|
91
|
+
}
|
|
92
|
+
host = { _s: bucket }
|
|
93
|
+
hosts.set(value, host)
|
|
70
94
|
}
|
|
71
|
-
host
|
|
72
|
-
hosts.set(value, host)
|
|
95
|
+
trackSubscriber(host)
|
|
73
96
|
}
|
|
74
|
-
trackSubscriber(host)
|
|
75
97
|
return Object.is(current, value)
|
|
98
|
+
}) as Selector<T>
|
|
99
|
+
|
|
100
|
+
selector.dispose = (): void => {
|
|
101
|
+
if (disposed) return
|
|
102
|
+
disposed = true
|
|
103
|
+
sourceEffect.dispose()
|
|
104
|
+
subs.clear()
|
|
105
|
+
hosts.clear()
|
|
76
106
|
}
|
|
107
|
+
|
|
108
|
+
return selector
|
|
77
109
|
}
|