@pyreon/reactivity 0.1.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/LICENSE +21 -0
- package/README.md +73 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +838 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +725 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +342 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +40 -0
- package/src/batch.ts +44 -0
- package/src/cell.ts +71 -0
- package/src/computed.ts +71 -0
- package/src/createSelector.ts +56 -0
- package/src/debug.ts +134 -0
- package/src/effect.ts +152 -0
- package/src/index.ts +15 -0
- package/src/reconcile.ts +98 -0
- package/src/resource.ts +66 -0
- package/src/scope.ts +80 -0
- package/src/signal.ts +125 -0
- package/src/store.ts +139 -0
- package/src/tests/batch.test.ts +69 -0
- package/src/tests/bind.test.ts +84 -0
- package/src/tests/branches.test.ts +343 -0
- package/src/tests/cell.test.ts +111 -0
- package/src/tests/computed.test.ts +146 -0
- package/src/tests/createSelector.test.ts +119 -0
- package/src/tests/debug.test.ts +196 -0
- package/src/tests/effect.test.ts +256 -0
- package/src/tests/resource.test.ts +133 -0
- package/src/tests/scope.test.ts +202 -0
- package/src/tests/signal.test.ts +120 -0
- package/src/tests/store.test.ts +136 -0
- package/src/tests/tracking.test.ts +158 -0
- package/src/tests/watch.test.ts +146 -0
- package/src/tracking.ts +103 -0
- package/src/watch.ts +69 -0
package/src/debug.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyreon/reactivity debug utilities.
|
|
3
|
+
*
|
|
4
|
+
* Development-only tools for tracing signal updates, inspecting reactive
|
|
5
|
+
* graphs, and understanding why DOM nodes re-render.
|
|
6
|
+
*
|
|
7
|
+
* All utilities are tree-shakeable — they compile away in production builds
|
|
8
|
+
* when unused.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Signal, SignalDebugInfo } from "./signal"
|
|
12
|
+
|
|
13
|
+
// ─── Signal update tracing ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface SignalUpdateEvent {
|
|
16
|
+
/** The signal that changed */
|
|
17
|
+
signal: Signal<unknown>
|
|
18
|
+
/** Signal name (from options or label) */
|
|
19
|
+
name: string | undefined
|
|
20
|
+
/** Previous value */
|
|
21
|
+
prev: unknown
|
|
22
|
+
/** New value */
|
|
23
|
+
next: unknown
|
|
24
|
+
/** Stack trace at the point of the .set() / .update() call */
|
|
25
|
+
stack: string
|
|
26
|
+
/** Timestamp */
|
|
27
|
+
timestamp: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type SignalUpdateListener = (event: SignalUpdateEvent) => void
|
|
31
|
+
|
|
32
|
+
let _traceListeners: SignalUpdateListener[] | null = null
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register a listener that fires on every signal write.
|
|
36
|
+
* Returns a dispose function.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* const dispose = onSignalUpdate(e => {
|
|
40
|
+
* console.log(`${e.name ?? 'anonymous'}: ${e.prev} → ${e.next}`)
|
|
41
|
+
* })
|
|
42
|
+
*/
|
|
43
|
+
export function onSignalUpdate(listener: SignalUpdateListener): () => void {
|
|
44
|
+
if (!_traceListeners) _traceListeners = []
|
|
45
|
+
_traceListeners.push(listener)
|
|
46
|
+
return () => {
|
|
47
|
+
if (!_traceListeners) return
|
|
48
|
+
_traceListeners = _traceListeners.filter((l) => l !== listener)
|
|
49
|
+
if (_traceListeners.length === 0) _traceListeners = null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @internal — called from signal.set() when tracing is active */
|
|
54
|
+
export function _notifyTraceListeners(sig: Signal<unknown>, prev: unknown, next: unknown): void {
|
|
55
|
+
if (!_traceListeners) return
|
|
56
|
+
const event: SignalUpdateEvent = {
|
|
57
|
+
signal: sig,
|
|
58
|
+
name: sig.label,
|
|
59
|
+
prev,
|
|
60
|
+
next,
|
|
61
|
+
stack: new Error().stack ?? "",
|
|
62
|
+
timestamp: performance.now(),
|
|
63
|
+
}
|
|
64
|
+
for (const l of _traceListeners) l(event)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if any trace listeners are active (fast path for signal.set) */
|
|
68
|
+
export function isTracing(): boolean {
|
|
69
|
+
return _traceListeners !== null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── why() — trace which signal caused a re-run ──────────────────────────────
|
|
73
|
+
|
|
74
|
+
let _whyActive = false
|
|
75
|
+
let _whyLog: { name: string | undefined; prev: unknown; next: unknown }[] = []
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Trace the next signal update. Logs which signals fire and what changed.
|
|
79
|
+
* Call before triggering a state change to see what updates and why.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* why()
|
|
83
|
+
* count.set(5)
|
|
84
|
+
* // Console: [pyreon:why] "count": 3 → 5 (2 subscribers)
|
|
85
|
+
*/
|
|
86
|
+
export function why(): void {
|
|
87
|
+
if (_whyActive) return
|
|
88
|
+
_whyActive = true
|
|
89
|
+
_whyLog = []
|
|
90
|
+
|
|
91
|
+
const dispose = onSignalUpdate((e) => {
|
|
92
|
+
const _subCount = (e.signal as unknown as { _s: Set<unknown> | null })._s?.size ?? 0
|
|
93
|
+
const _name = e.name ? `"${e.name}"` : "(anonymous signal)"
|
|
94
|
+
|
|
95
|
+
console.log(
|
|
96
|
+
`[pyreon:why] ${_name}: ${JSON.stringify(e.prev)} → ${JSON.stringify(e.next)} (${_subCount} subscriber${_subCount === 1 ? "" : "s"})`,
|
|
97
|
+
)
|
|
98
|
+
_whyLog.push({ name: e.name, prev: e.prev, next: e.next })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Auto-dispose after the current microtask (captures the synchronous batch)
|
|
102
|
+
queueMicrotask(() => {
|
|
103
|
+
dispose()
|
|
104
|
+
if (_whyLog.length === 0) {
|
|
105
|
+
console.log("[pyreon:why] No signal updates detected")
|
|
106
|
+
}
|
|
107
|
+
_whyActive = false
|
|
108
|
+
_whyLog = []
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── inspectSignal — rich console output ─────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Print a signal's current state to the console in a readable format.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* const count = signal(42, { name: "count" })
|
|
119
|
+
* inspectSignal(count)
|
|
120
|
+
* // Console:
|
|
121
|
+
* // 🔍 Signal "count"
|
|
122
|
+
* // value: 42
|
|
123
|
+
* // subscribers: 3
|
|
124
|
+
*/
|
|
125
|
+
export function inspectSignal<T>(sig: Signal<T>): SignalDebugInfo<T> {
|
|
126
|
+
const info = sig.debug()
|
|
127
|
+
|
|
128
|
+
console.group(`🔍 Signal ${info.name ? `"${info.name}"` : "(anonymous)"}`)
|
|
129
|
+
console.log("value:", info.value)
|
|
130
|
+
console.log("subscribers:", info.subscriberCount)
|
|
131
|
+
console.groupEnd()
|
|
132
|
+
|
|
133
|
+
return info
|
|
134
|
+
}
|
package/src/effect.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { getCurrentScope } from "./scope"
|
|
2
|
+
import { cleanupEffect, setDepsCollector, withTracking } from "./tracking"
|
|
3
|
+
|
|
4
|
+
export interface Effect {
|
|
5
|
+
dispose(): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Global error handler — called for unhandled errors thrown inside effects.
|
|
9
|
+
// Defaults to console.error so silent failures are never swallowed.
|
|
10
|
+
let _errorHandler: (err: unknown) => void = (err) => {
|
|
11
|
+
console.error("[pyreon] Unhandled effect error:", err)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setErrorHandler(fn: (err: unknown) => void): void {
|
|
15
|
+
_errorHandler = fn
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: void is intentional — callbacks that return nothing must be assignable
|
|
19
|
+
export function effect(fn: () => (() => void) | void): Effect {
|
|
20
|
+
// Capture the scope at creation time — remains correct during future re-runs
|
|
21
|
+
// even after setCurrentScope(null) has been called post-setup.
|
|
22
|
+
const scope = getCurrentScope()
|
|
23
|
+
let disposed = false
|
|
24
|
+
let isFirstRun = true
|
|
25
|
+
let cleanup: (() => void) | undefined
|
|
26
|
+
|
|
27
|
+
const runCleanup = () => {
|
|
28
|
+
if (typeof cleanup === "function") {
|
|
29
|
+
try {
|
|
30
|
+
cleanup()
|
|
31
|
+
} catch (err) {
|
|
32
|
+
_errorHandler(err)
|
|
33
|
+
}
|
|
34
|
+
cleanup = undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const run = () => {
|
|
39
|
+
if (disposed) return
|
|
40
|
+
// Run previous cleanup before re-running
|
|
41
|
+
runCleanup()
|
|
42
|
+
// Clean up previous subscriptions before re-running (dynamic dep tracking)
|
|
43
|
+
cleanupEffect(run)
|
|
44
|
+
try {
|
|
45
|
+
cleanup = withTracking(run, fn) || undefined
|
|
46
|
+
} catch (err) {
|
|
47
|
+
_errorHandler(err)
|
|
48
|
+
}
|
|
49
|
+
// Notify scope after each reactive re-run (not the initial synchronous run)
|
|
50
|
+
// so onUpdate hooks fire after the DOM has settled.
|
|
51
|
+
if (!isFirstRun) scope?.notifyEffectRan()
|
|
52
|
+
isFirstRun = false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
run()
|
|
56
|
+
|
|
57
|
+
const e: Effect = {
|
|
58
|
+
dispose() {
|
|
59
|
+
runCleanup()
|
|
60
|
+
disposed = true
|
|
61
|
+
cleanupEffect(run)
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Auto-register with the active EffectScope (if any)
|
|
66
|
+
getCurrentScope()?.add(e)
|
|
67
|
+
|
|
68
|
+
return e
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Lightweight effect for DOM render bindings.
|
|
73
|
+
*
|
|
74
|
+
* Differences from `effect()`:
|
|
75
|
+
* - No EffectScope registration (caller owns the dispose lifecycle)
|
|
76
|
+
* - No error handler (errors propagate naturally)
|
|
77
|
+
* - No onUpdate notification
|
|
78
|
+
* - Deps stored in a local array instead of the global WeakMap — faster
|
|
79
|
+
* creation and disposal (~200ns saved per effect vs WeakMap path)
|
|
80
|
+
*
|
|
81
|
+
* Returns a dispose function (not an Effect object — saves 1 allocation).
|
|
82
|
+
*/
|
|
83
|
+
/**
|
|
84
|
+
* Static-dep binding — compiler helper for template expressions.
|
|
85
|
+
*
|
|
86
|
+
* Like renderEffect but assumes dependencies never change (true for all
|
|
87
|
+
* compiler-emitted template bindings like `_tpl()` text/attribute updates).
|
|
88
|
+
*
|
|
89
|
+
* Tracks dependencies only on the first run. Re-runs skip cleanup, re-tracking,
|
|
90
|
+
* and tracking context save/restore entirely — just calls `fn()` directly.
|
|
91
|
+
*
|
|
92
|
+
* Per re-run savings vs renderEffect:
|
|
93
|
+
* - No deps iteration + Set.delete (cleanup)
|
|
94
|
+
* - No setDepsCollector + withTracking (re-registration)
|
|
95
|
+
* - Signal reads hit `if (activeEffect)` null check → instant return
|
|
96
|
+
*/
|
|
97
|
+
export function _bind(fn: () => void): () => void {
|
|
98
|
+
const deps: Set<() => void>[] = []
|
|
99
|
+
let disposed = false
|
|
100
|
+
|
|
101
|
+
const run = () => {
|
|
102
|
+
if (disposed) return
|
|
103
|
+
fn()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// First run: track deps so we know what to unsubscribe on dispose
|
|
107
|
+
setDepsCollector(deps)
|
|
108
|
+
withTracking(run, fn)
|
|
109
|
+
setDepsCollector(null)
|
|
110
|
+
|
|
111
|
+
const dispose = () => {
|
|
112
|
+
if (disposed) return
|
|
113
|
+
disposed = true
|
|
114
|
+
for (const s of deps) s.delete(run)
|
|
115
|
+
deps.length = 0
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Auto-register with scope so template bindings are disposed during teardown
|
|
119
|
+
getCurrentScope()?.add({ dispose })
|
|
120
|
+
|
|
121
|
+
return dispose
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderEffect(fn: () => void): () => void {
|
|
125
|
+
const deps: Set<() => void>[] = []
|
|
126
|
+
let disposed = false
|
|
127
|
+
|
|
128
|
+
const run = () => {
|
|
129
|
+
if (disposed) return
|
|
130
|
+
// Clean up old subscriptions
|
|
131
|
+
for (const s of deps) s.delete(run)
|
|
132
|
+
deps.length = 0
|
|
133
|
+
// Track with fast collector — pushes to our local deps array
|
|
134
|
+
setDepsCollector(deps)
|
|
135
|
+
withTracking(run, fn)
|
|
136
|
+
setDepsCollector(null)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
run()
|
|
140
|
+
|
|
141
|
+
const dispose = () => {
|
|
142
|
+
if (disposed) return
|
|
143
|
+
disposed = true
|
|
144
|
+
for (const s of deps) s.delete(run)
|
|
145
|
+
deps.length = 0
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Auto-register with scope so render effects are disposed during teardown
|
|
149
|
+
getCurrentScope()?.add({ dispose })
|
|
150
|
+
|
|
151
|
+
return dispose
|
|
152
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @pyreon/reactivity — signals-based reactive primitives
|
|
2
|
+
|
|
3
|
+
export { batch, nextTick } from "./batch"
|
|
4
|
+
export { Cell, cell } from "./cell"
|
|
5
|
+
export { type Computed, type ComputedOptions, computed } from "./computed"
|
|
6
|
+
export { createSelector } from "./createSelector"
|
|
7
|
+
export { inspectSignal, onSignalUpdate, why } from "./debug"
|
|
8
|
+
export { _bind, type Effect, effect, renderEffect, setErrorHandler } from "./effect"
|
|
9
|
+
export { reconcile } from "./reconcile"
|
|
10
|
+
export { createResource, type Resource } from "./resource"
|
|
11
|
+
export { EffectScope, effectScope, getCurrentScope, setCurrentScope } from "./scope"
|
|
12
|
+
export { type Signal, type SignalDebugInfo, type SignalOptions, signal } from "./signal"
|
|
13
|
+
export { createStore, isStore } from "./store"
|
|
14
|
+
export { runUntracked } from "./tracking"
|
|
15
|
+
export { type WatchOptions, watch } from "./watch"
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reconcile — surgically diff new state into an existing createStore proxy.
|
|
3
|
+
*
|
|
4
|
+
* Instead of replacing the store root (which would trigger all downstream effects),
|
|
5
|
+
* reconcile walks both the new value and the store in parallel and only calls
|
|
6
|
+
* `.set()` on signals whose value actually changed.
|
|
7
|
+
*
|
|
8
|
+
* Ideal for applying API responses to a long-lived store:
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* const state = createStore({ user: { name: "Alice", age: 30 }, items: [] })
|
|
12
|
+
*
|
|
13
|
+
* // API response arrives:
|
|
14
|
+
* reconcile({ user: { name: "Alice", age: 31 }, items: [{ id: 1 }] }, state)
|
|
15
|
+
* // → only state.user.age signal fires (name unchanged)
|
|
16
|
+
* // → state.items[0] is newly created
|
|
17
|
+
*
|
|
18
|
+
* Arrays are reconciled by index — elements at the same index are recursively
|
|
19
|
+
* diffed rather than replaced wholesale. Excess old elements are removed.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isStore } from "./store"
|
|
23
|
+
|
|
24
|
+
type AnyObject = Record<PropertyKey, unknown>
|
|
25
|
+
|
|
26
|
+
export function reconcile<T extends object>(source: T, target: T): void {
|
|
27
|
+
_reconcileInner(source, target, new WeakSet())
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _reconcileInner(source: object, target: object, seen: WeakSet<object>): void {
|
|
31
|
+
if (seen.has(source)) return // circular reference — stop recursion
|
|
32
|
+
seen.add(source)
|
|
33
|
+
if (Array.isArray(source) && Array.isArray(target)) {
|
|
34
|
+
_reconcileArray(source as unknown[], target as unknown[], seen)
|
|
35
|
+
} else {
|
|
36
|
+
_reconcileObject(source as AnyObject, target as AnyObject, seen)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _reconcileArray(source: unknown[], target: unknown[], seen: WeakSet<object>): void {
|
|
41
|
+
const targetLen = target.length
|
|
42
|
+
const sourceLen = source.length
|
|
43
|
+
|
|
44
|
+
// Update / add entries
|
|
45
|
+
for (let i = 0; i < sourceLen; i++) {
|
|
46
|
+
const sv = source[i]
|
|
47
|
+
const tv = (target as unknown[])[i]
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
i < targetLen &&
|
|
51
|
+
sv !== null &&
|
|
52
|
+
typeof sv === "object" &&
|
|
53
|
+
tv !== null &&
|
|
54
|
+
typeof tv === "object"
|
|
55
|
+
) {
|
|
56
|
+
// Both sides are objects — recurse
|
|
57
|
+
_reconcileInner(sv as object, tv as object, seen)
|
|
58
|
+
} else {
|
|
59
|
+
// Scalar or new entry — write directly (signal will skip if equal via Object.is)
|
|
60
|
+
;(target as unknown[])[i] = sv
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Trim excess entries
|
|
65
|
+
if (targetLen > sourceLen) {
|
|
66
|
+
target.length = sourceLen
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function _reconcileObject(source: AnyObject, target: AnyObject, seen: WeakSet<object>): void {
|
|
71
|
+
const sourceKeys = Object.keys(source)
|
|
72
|
+
const targetKeys = new Set(Object.keys(target))
|
|
73
|
+
|
|
74
|
+
for (const key of sourceKeys) {
|
|
75
|
+
const sv = source[key]
|
|
76
|
+
const tv = target[key]
|
|
77
|
+
|
|
78
|
+
if (sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") {
|
|
79
|
+
if (isStore(tv)) {
|
|
80
|
+
// Both objects — recurse into the store node
|
|
81
|
+
_reconcileInner(sv as object, tv as object, seen)
|
|
82
|
+
} else {
|
|
83
|
+
// Target is a raw object (not yet proxied) — just assign
|
|
84
|
+
target[key] = sv
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
// Scalar: assign (store proxy's set trap skips if Object.is equal)
|
|
88
|
+
target[key] = sv
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
targetKeys.delete(key)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Remove keys that no longer exist in source
|
|
95
|
+
for (const key of targetKeys) {
|
|
96
|
+
delete target[key]
|
|
97
|
+
}
|
|
98
|
+
}
|
package/src/resource.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { effect } from "./effect"
|
|
2
|
+
import type { Signal } from "./signal"
|
|
3
|
+
import { signal } from "./signal"
|
|
4
|
+
import { runUntracked } from "./tracking"
|
|
5
|
+
|
|
6
|
+
export interface Resource<T> {
|
|
7
|
+
/** The latest resolved value (undefined while loading or on error). */
|
|
8
|
+
data: Signal<T | undefined>
|
|
9
|
+
/** True while a fetch is in flight. */
|
|
10
|
+
loading: Signal<boolean>
|
|
11
|
+
/** The last error thrown by the fetcher, or undefined. */
|
|
12
|
+
error: Signal<unknown>
|
|
13
|
+
/** Re-run the fetcher with the current source value. */
|
|
14
|
+
refetch(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Async data primitive. Fetches data reactively whenever `source()` changes.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const userId = signal(1)
|
|
22
|
+
* const user = createResource(userId, (id) => fetchUser(id))
|
|
23
|
+
* // user.data() — the fetched user (undefined while loading)
|
|
24
|
+
* // user.loading() — true while in flight
|
|
25
|
+
* // user.error() — last error
|
|
26
|
+
*/
|
|
27
|
+
export function createResource<T, P>(
|
|
28
|
+
source: () => P,
|
|
29
|
+
fetcher: (param: P) => Promise<T>,
|
|
30
|
+
): Resource<T> {
|
|
31
|
+
const data = signal<T | undefined>(undefined)
|
|
32
|
+
const loading = signal(false)
|
|
33
|
+
const error = signal<unknown>(undefined)
|
|
34
|
+
let requestId = 0
|
|
35
|
+
|
|
36
|
+
const doFetch = (param: P) => {
|
|
37
|
+
const id = ++requestId
|
|
38
|
+
loading.set(true)
|
|
39
|
+
error.set(undefined)
|
|
40
|
+
fetcher(param)
|
|
41
|
+
.then((result) => {
|
|
42
|
+
if (id !== requestId) return
|
|
43
|
+
data.set(result)
|
|
44
|
+
loading.set(false)
|
|
45
|
+
})
|
|
46
|
+
.catch((err: unknown) => {
|
|
47
|
+
if (id !== requestId) return
|
|
48
|
+
error.set(err)
|
|
49
|
+
loading.set(false)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
effect(() => {
|
|
54
|
+
const param = source()
|
|
55
|
+
runUntracked(() => doFetch(param))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
data,
|
|
60
|
+
loading,
|
|
61
|
+
error,
|
|
62
|
+
refetch() {
|
|
63
|
+
runUntracked(() => doFetch(source()))
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// EffectScope — auto-tracks effects created during a component's setup
|
|
2
|
+
// and disposes them all at once when the component unmounts.
|
|
3
|
+
|
|
4
|
+
export class EffectScope {
|
|
5
|
+
private _effects: { dispose(): void }[] = []
|
|
6
|
+
private _active = true
|
|
7
|
+
private _updateHooks: (() => void)[] = []
|
|
8
|
+
private _updatePending = false
|
|
9
|
+
|
|
10
|
+
/** Register an effect/computed to be disposed when this scope stops. */
|
|
11
|
+
add(e: { dispose(): void }): void {
|
|
12
|
+
if (this._active) this._effects.push(e)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Temporarily re-activate this scope so effects created inside `fn` are
|
|
17
|
+
* auto-tracked and will be disposed when the scope stops.
|
|
18
|
+
* Used to ensure effects created in `onMount` callbacks belong to their
|
|
19
|
+
* component's scope rather than leaking as global effects.
|
|
20
|
+
*/
|
|
21
|
+
runInScope<T>(fn: () => T): T {
|
|
22
|
+
const prev = _currentScope
|
|
23
|
+
_currentScope = this
|
|
24
|
+
try {
|
|
25
|
+
return fn()
|
|
26
|
+
} finally {
|
|
27
|
+
_currentScope = prev
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Register a callback to run after any reactive update in this scope. */
|
|
32
|
+
addUpdateHook(fn: () => void): void {
|
|
33
|
+
this._updateHooks.push(fn)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Called by effects after each non-initial re-run.
|
|
38
|
+
* Schedules onUpdate hooks via microtask so all synchronous effects settle first.
|
|
39
|
+
*/
|
|
40
|
+
notifyEffectRan(): void {
|
|
41
|
+
if (!this._active || this._updateHooks.length === 0 || this._updatePending) return
|
|
42
|
+
this._updatePending = true
|
|
43
|
+
queueMicrotask(() => {
|
|
44
|
+
this._updatePending = false
|
|
45
|
+
if (!this._active) return
|
|
46
|
+
for (const fn of this._updateHooks) {
|
|
47
|
+
try {
|
|
48
|
+
fn()
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error("[pyreon] onUpdate hook error:", err)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Dispose all tracked effects. */
|
|
57
|
+
stop(): void {
|
|
58
|
+
if (!this._active) return
|
|
59
|
+
for (const e of this._effects) e.dispose()
|
|
60
|
+
this._effects = []
|
|
61
|
+
this._updateHooks = []
|
|
62
|
+
this._updatePending = false
|
|
63
|
+
this._active = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let _currentScope: EffectScope | null = null
|
|
68
|
+
|
|
69
|
+
export function getCurrentScope(): EffectScope | null {
|
|
70
|
+
return _currentScope
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function setCurrentScope(scope: EffectScope | null): void {
|
|
74
|
+
_currentScope = scope
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Create a new EffectScope. */
|
|
78
|
+
export function effectScope(): EffectScope {
|
|
79
|
+
return new EffectScope()
|
|
80
|
+
}
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { _notifyTraceListeners, isTracing } from "./debug"
|
|
2
|
+
import { notifySubscribers, trackSubscriber } from "./tracking"
|
|
3
|
+
|
|
4
|
+
export interface SignalDebugInfo<T> {
|
|
5
|
+
/** Signal name (set via options or inferred) */
|
|
6
|
+
name: string | undefined
|
|
7
|
+
/** Current value (same as peek()) */
|
|
8
|
+
value: T
|
|
9
|
+
/** Number of active subscribers */
|
|
10
|
+
subscriberCount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Signal<T> {
|
|
14
|
+
(): T
|
|
15
|
+
/** Read the current value WITHOUT registering a reactive dependency. */
|
|
16
|
+
peek(): T
|
|
17
|
+
set(value: T): void
|
|
18
|
+
update(fn: (current: T) => T): void
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe a static listener directly — no effect overhead (no withTracking,
|
|
21
|
+
* no cleanupEffect, no effectDeps WeakMap). Use when the dependency is fixed
|
|
22
|
+
* and dynamic re-tracking is not needed.
|
|
23
|
+
* Returns a disposer that removes the subscription.
|
|
24
|
+
*/
|
|
25
|
+
subscribe(listener: () => void): () => void
|
|
26
|
+
/** Debug name — useful for devtools and logging. */
|
|
27
|
+
label: string | undefined
|
|
28
|
+
/** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
|
|
29
|
+
debug(): SignalDebugInfo<T>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SignalOptions {
|
|
33
|
+
/** Debug name for this signal — shows up in devtools and debug() output. */
|
|
34
|
+
name?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Internal shape of a signal function — state stored as properties on the
|
|
38
|
+
// function object so methods can be shared via assignment (not per-signal closures).
|
|
39
|
+
interface SignalFn<T> {
|
|
40
|
+
(): T
|
|
41
|
+
/** @internal current value */
|
|
42
|
+
_v: T
|
|
43
|
+
/** @internal subscriber set (lazily allocated by trackSubscriber) */
|
|
44
|
+
_s: Set<() => void> | null
|
|
45
|
+
/** @internal debug name */
|
|
46
|
+
_n: string | undefined
|
|
47
|
+
peek(): T
|
|
48
|
+
set(value: T): void
|
|
49
|
+
update(fn: (current: T) => T): void
|
|
50
|
+
subscribe(listener: () => void): () => void
|
|
51
|
+
label: string | undefined
|
|
52
|
+
debug(): SignalDebugInfo<T>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Shared method implementations — defined once, assigned to every signal.
|
|
56
|
+
// Uses `this` binding (signal methods are always called as `signal.method()`).
|
|
57
|
+
function _peek(this: SignalFn<unknown>) {
|
|
58
|
+
return this._v
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _set(this: SignalFn<unknown>, newValue: unknown) {
|
|
62
|
+
if (Object.is(this._v, newValue)) return
|
|
63
|
+
const prev = this._v
|
|
64
|
+
this._v = newValue
|
|
65
|
+
if (isTracing()) _notifyTraceListeners(this as unknown as Signal<unknown>, prev, newValue)
|
|
66
|
+
if (this._s) notifySubscribers(this._s)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _update(this: SignalFn<unknown>, fn: (current: unknown) => unknown) {
|
|
70
|
+
_set.call(this, fn(this._v))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _subscribe(this: SignalFn<unknown>, listener: () => void): () => void {
|
|
74
|
+
if (!this._s) this._s = new Set()
|
|
75
|
+
this._s.add(listener)
|
|
76
|
+
return () => this._s?.delete(listener)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _debug(this: SignalFn<unknown>): SignalDebugInfo<unknown> {
|
|
80
|
+
return {
|
|
81
|
+
name: this._n,
|
|
82
|
+
value: this._v,
|
|
83
|
+
subscriberCount: this._s?.size ?? 0,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// label getter/setter — maps to _n for devtools-friendly access
|
|
88
|
+
const _labelDescriptor: PropertyDescriptor = {
|
|
89
|
+
get(this: SignalFn<unknown>) {
|
|
90
|
+
return this._n
|
|
91
|
+
},
|
|
92
|
+
set(this: SignalFn<unknown>, v: string | undefined) {
|
|
93
|
+
this._n = v
|
|
94
|
+
},
|
|
95
|
+
enumerable: false,
|
|
96
|
+
configurable: true,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Create a reactive signal.
|
|
101
|
+
*
|
|
102
|
+
* Only 1 closure is allocated (the read function). State is stored as
|
|
103
|
+
* properties on the function object (_v, _s) and methods (peek, set,
|
|
104
|
+
* update, subscribe) are shared across all signals — not per-signal closures.
|
|
105
|
+
*/
|
|
106
|
+
export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
|
|
107
|
+
// The read function is the only per-signal closure.
|
|
108
|
+
// It doubles as the SubscriberHost (_s property) for trackSubscriber.
|
|
109
|
+
const read = (() => {
|
|
110
|
+
trackSubscriber(read as SignalFn<T>)
|
|
111
|
+
return read._v
|
|
112
|
+
}) as unknown as SignalFn<T>
|
|
113
|
+
|
|
114
|
+
read._v = initialValue
|
|
115
|
+
read._s = null
|
|
116
|
+
read._n = options?.name
|
|
117
|
+
read.peek = _peek as () => T
|
|
118
|
+
read.set = _set as (value: T) => void
|
|
119
|
+
read.update = _update as (fn: (current: T) => T) => void
|
|
120
|
+
read.subscribe = _subscribe as (listener: () => void) => () => void
|
|
121
|
+
read.debug = _debug as () => SignalDebugInfo<T>
|
|
122
|
+
Object.defineProperty(read, "label", _labelDescriptor)
|
|
123
|
+
|
|
124
|
+
return read as unknown as Signal<T>
|
|
125
|
+
}
|