@pyreon/reactivity 0.24.4 → 0.24.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -4
- package/src/batch.ts +0 -196
- package/src/cell.ts +0 -72
- package/src/computed.ts +0 -313
- package/src/createSelector.ts +0 -109
- package/src/debug.ts +0 -134
- package/src/effect.ts +0 -467
- package/src/env.d.ts +0 -6
- package/src/index.ts +0 -60
- package/src/lpih.ts +0 -227
- package/src/manifest.ts +0 -660
- package/src/reactive-devtools.ts +0 -494
- package/src/reactive-trace.ts +0 -142
- package/src/reconcile.ts +0 -118
- package/src/resource.ts +0 -84
- package/src/scope.ts +0 -123
- package/src/signal.ts +0 -261
- package/src/store.ts +0 -250
- package/src/tests/batch.test.ts +0 -751
- package/src/tests/bind.test.ts +0 -84
- package/src/tests/branches.test.ts +0 -343
- package/src/tests/cell.test.ts +0 -159
- package/src/tests/computed.test.ts +0 -436
- package/src/tests/coverage-hardening.test.ts +0 -471
- package/src/tests/createSelector.test.ts +0 -291
- package/src/tests/debug.test.ts +0 -196
- package/src/tests/effect.test.ts +0 -464
- package/src/tests/fanout-repro.test.ts +0 -179
- package/src/tests/lpih-source-location.test.ts +0 -277
- package/src/tests/lpih.test.ts +0 -351
- package/src/tests/manifest-snapshot.test.ts +0 -96
- package/src/tests/reactive-devtools-treeshake.test.ts +0 -48
- package/src/tests/reactive-devtools.test.ts +0 -296
- package/src/tests/reactive-trace.test.ts +0 -102
- package/src/tests/reconcile-security.test.ts +0 -45
- package/src/tests/resource.test.ts +0 -326
- package/src/tests/scope.test.ts +0 -231
- package/src/tests/signal.test.ts +0 -368
- package/src/tests/store.test.ts +0 -286
- package/src/tests/tracking.test.ts +0 -158
- package/src/tests/vue-parity.test.ts +0 -191
- package/src/tests/watch.test.ts +0 -246
- package/src/tracking.ts +0 -139
- package/src/watch.ts +0 -68
package/src/reconcile.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
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
|
-
// Keys that, written through the bracket-assignment paths below, would
|
|
27
|
-
// mutate Object.prototype (or a constructor's prototype) instead of the
|
|
28
|
-
// store. `reconcile` is explicitly documented for applying API responses
|
|
29
|
-
// directly (`reconcile(JSON.parse(body), store)`), and
|
|
30
|
-
// `JSON.parse('{"__proto__":{…}}')` yields an OWN enumerable `__proto__`
|
|
31
|
-
// key that `Object.keys` returns — the canonical prototype-pollution
|
|
32
|
-
// merge vector. Skip these unconditionally on both the write and the
|
|
33
|
-
// stale-key-removal pass.
|
|
34
|
-
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
|
35
|
-
|
|
36
|
-
export function reconcile<T extends object>(source: T, target: T): void {
|
|
37
|
-
_reconcileInner(source, target, new WeakSet())
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function _reconcileInner(source: object, target: object, seen: WeakSet<object>): void {
|
|
41
|
-
// The `seen` set is keyed on `source`, not `target` — protects against
|
|
42
|
-
// CIRCULAR references in the source tree (avoids infinite recursion). A
|
|
43
|
-
// consequence: DIAMOND-shaped sources (the SAME nested object referenced
|
|
44
|
-
// from two different parent paths in `source`) only get reconciled into
|
|
45
|
-
// their FIRST encountered position in `target`. The second occurrence is
|
|
46
|
-
// skipped. This is intentional — reconcile assumes source is a tree, not
|
|
47
|
-
// a DAG. Pass distinct object references (or deep-clone before reconcile)
|
|
48
|
-
// if your source is a DAG.
|
|
49
|
-
if (seen.has(source)) return
|
|
50
|
-
seen.add(source)
|
|
51
|
-
if (Array.isArray(source) && Array.isArray(target)) {
|
|
52
|
-
_reconcileArray(source as unknown[], target as unknown[], seen)
|
|
53
|
-
} else {
|
|
54
|
-
_reconcileObject(source as AnyObject, target as AnyObject, seen)
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function _reconcileArray(source: unknown[], target: unknown[], seen: WeakSet<object>): void {
|
|
59
|
-
const targetLen = target.length
|
|
60
|
-
const sourceLen = source.length
|
|
61
|
-
|
|
62
|
-
// Update / add entries
|
|
63
|
-
for (let i = 0; i < sourceLen; i++) {
|
|
64
|
-
const sv = source[i]
|
|
65
|
-
const tv = (target as unknown[])[i]
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
i < targetLen &&
|
|
69
|
-
sv !== null &&
|
|
70
|
-
typeof sv === 'object' &&
|
|
71
|
-
tv !== null &&
|
|
72
|
-
typeof tv === 'object'
|
|
73
|
-
) {
|
|
74
|
-
// Both sides are objects — recurse
|
|
75
|
-
_reconcileInner(sv as object, tv as object, seen)
|
|
76
|
-
} else {
|
|
77
|
-
// Scalar or new entry — write directly (signal will skip if equal via Object.is)
|
|
78
|
-
;(target as unknown[])[i] = sv
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Trim excess entries
|
|
83
|
-
if (targetLen > sourceLen) {
|
|
84
|
-
target.length = sourceLen
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function _reconcileObject(source: AnyObject, target: AnyObject, seen: WeakSet<object>): void {
|
|
89
|
-
const sourceKeys = Object.keys(source)
|
|
90
|
-
const targetKeys = new Set(Object.keys(target))
|
|
91
|
-
|
|
92
|
-
for (const key of sourceKeys) {
|
|
93
|
-
if (DANGEROUS_KEYS.has(key)) continue
|
|
94
|
-
const sv = source[key]
|
|
95
|
-
const tv = target[key]
|
|
96
|
-
|
|
97
|
-
if (sv !== null && typeof sv === 'object' && tv !== null && typeof tv === 'object') {
|
|
98
|
-
if (isStore(tv)) {
|
|
99
|
-
// Both objects — recurse into the store node
|
|
100
|
-
_reconcileInner(sv as object, tv as object, seen)
|
|
101
|
-
} else {
|
|
102
|
-
// Target is a raw object (not yet proxied) — just assign
|
|
103
|
-
target[key] = sv
|
|
104
|
-
}
|
|
105
|
-
} else {
|
|
106
|
-
// Scalar: assign (store proxy's set trap skips if Object.is equal)
|
|
107
|
-
target[key] = sv
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
targetKeys.delete(key)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Remove keys that no longer exist in source
|
|
114
|
-
for (const key of targetKeys) {
|
|
115
|
-
if (DANGEROUS_KEYS.has(key)) continue
|
|
116
|
-
delete target[key]
|
|
117
|
-
}
|
|
118
|
-
}
|
package/src/resource.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
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
|
-
* 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
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Async data primitive. Fetches data reactively whenever `source()` changes.
|
|
26
|
-
*
|
|
27
|
-
* @example
|
|
28
|
-
* const userId = signal(1)
|
|
29
|
-
* const user = createResource(userId, (id) => fetchUser(id))
|
|
30
|
-
* // user.data() — the fetched user (undefined while loading)
|
|
31
|
-
* // user.loading() — true while in flight
|
|
32
|
-
* // user.error() — last error
|
|
33
|
-
*/
|
|
34
|
-
export function createResource<T, P>(
|
|
35
|
-
source: () => P,
|
|
36
|
-
fetcher: (param: P) => Promise<T>,
|
|
37
|
-
): Resource<T> {
|
|
38
|
-
const data = signal<T | undefined>(undefined)
|
|
39
|
-
const loading = signal(false)
|
|
40
|
-
const error = signal<unknown>(undefined)
|
|
41
|
-
let requestId = 0
|
|
42
|
-
|
|
43
|
-
const doFetch = (param: P) => {
|
|
44
|
-
const id = ++requestId
|
|
45
|
-
loading.set(true)
|
|
46
|
-
error.set(undefined)
|
|
47
|
-
fetcher(param)
|
|
48
|
-
.then((result) => {
|
|
49
|
-
if (id !== requestId) return
|
|
50
|
-
data.set(result)
|
|
51
|
-
loading.set(false)
|
|
52
|
-
})
|
|
53
|
-
.catch((err: unknown) => {
|
|
54
|
-
if (id !== requestId) return
|
|
55
|
-
error.set(err)
|
|
56
|
-
loading.set(false)
|
|
57
|
-
})
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let disposed = false
|
|
61
|
-
const sourceEffect = effect(() => {
|
|
62
|
-
const param = source()
|
|
63
|
-
runUntracked(() => doFetch(param))
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
data,
|
|
68
|
-
loading,
|
|
69
|
-
error,
|
|
70
|
-
refetch() {
|
|
71
|
-
if (disposed) return
|
|
72
|
-
runUntracked(() => doFetch(source()))
|
|
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
|
-
},
|
|
83
|
-
}
|
|
84
|
-
}
|
package/src/scope.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
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 }[] | null = null
|
|
6
|
-
private _active = true
|
|
7
|
-
private _updateHooks: (() => void)[] | null = null
|
|
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) return
|
|
13
|
-
if (this._effects === null) this._effects = []
|
|
14
|
-
this._effects.push(e)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Temporarily re-activate this scope so effects created inside `fn` are
|
|
19
|
-
* auto-tracked and will be disposed when the scope stops.
|
|
20
|
-
* Used to ensure effects created in `onMount` callbacks belong to their
|
|
21
|
-
* component's scope rather than leaking as global effects.
|
|
22
|
-
*/
|
|
23
|
-
runInScope<T>(fn: () => T): T {
|
|
24
|
-
const prev = _currentScope
|
|
25
|
-
_currentScope = this
|
|
26
|
-
try {
|
|
27
|
-
return fn()
|
|
28
|
-
} finally {
|
|
29
|
-
_currentScope = prev
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Register a callback to run after any reactive update in this scope. */
|
|
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
|
|
41
|
-
if (this._updateHooks === null) this._updateHooks = []
|
|
42
|
-
this._updateHooks.push(fn)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Called by effects after each non-initial re-run.
|
|
47
|
-
* Schedules onUpdate hooks via microtask so all synchronous effects settle first.
|
|
48
|
-
*/
|
|
49
|
-
notifyEffectRan(): void {
|
|
50
|
-
if (!this._active || !this._updateHooks || this._updateHooks.length === 0 || this._updatePending) return
|
|
51
|
-
this._updatePending = true
|
|
52
|
-
queueMicrotask(() => {
|
|
53
|
-
this._updatePending = false
|
|
54
|
-
if (!this._active || !this._updateHooks) return
|
|
55
|
-
for (const fn of this._updateHooks) {
|
|
56
|
-
try {
|
|
57
|
-
fn()
|
|
58
|
-
} catch (err) {
|
|
59
|
-
console.error('[pyreon] onUpdate hook error:', err)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
})
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Dispose all tracked effects. */
|
|
66
|
-
stop(): void {
|
|
67
|
-
if (!this._active) return
|
|
68
|
-
if (this._effects) {
|
|
69
|
-
for (const e of this._effects) e.dispose()
|
|
70
|
-
}
|
|
71
|
-
this._effects = null
|
|
72
|
-
this._updateHooks = null
|
|
73
|
-
this._updatePending = false
|
|
74
|
-
this._active = false
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
let _currentScope: EffectScope | null = null
|
|
79
|
-
|
|
80
|
-
export function getCurrentScope(): EffectScope | null {
|
|
81
|
-
return _currentScope
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function setCurrentScope(scope: EffectScope | null): void {
|
|
85
|
-
_currentScope = scope
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Create a new EffectScope. */
|
|
89
|
-
export function effectScope(): EffectScope {
|
|
90
|
-
return new EffectScope()
|
|
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
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
import { batch, enqueuePendingNotification, isBatching } from './batch'
|
|
2
|
-
import { _notifyTraceListeners, isTracing } from './debug'
|
|
3
|
-
import { _captureCallerLocation, _rdRecordFire, _rdRegister } from './reactive-devtools'
|
|
4
|
-
import { _recordSignalWrite } from './reactive-trace'
|
|
5
|
-
import { notifySubscribers, trackSubscriber } from './tracking'
|
|
6
|
-
|
|
7
|
-
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
8
|
-
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
9
|
-
|
|
10
|
-
export interface SignalDebugInfo<T> {
|
|
11
|
-
/** Signal name (set via options or inferred) */
|
|
12
|
-
name: string | undefined
|
|
13
|
-
/** Current value (same as peek()) */
|
|
14
|
-
value: T
|
|
15
|
-
/** Number of active subscribers */
|
|
16
|
-
subscriberCount: number
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Read-only reactive value — the common interface that both Signal and Computed satisfy.
|
|
21
|
-
* Use this as the parameter type when a function only needs to read a reactive value.
|
|
22
|
-
*/
|
|
23
|
-
export type ReadonlySignal<T> = () => T
|
|
24
|
-
|
|
25
|
-
export interface Signal<T> {
|
|
26
|
-
(): T
|
|
27
|
-
/** Read the current value WITHOUT registering a reactive dependency. */
|
|
28
|
-
peek(): T
|
|
29
|
-
set(value: T): void
|
|
30
|
-
update(fn: (current: T) => T): void
|
|
31
|
-
/**
|
|
32
|
-
* Subscribe a static listener directly — no effect overhead (no withTracking,
|
|
33
|
-
* no cleanupEffect, no effectDeps WeakMap). Use when the dependency is fixed
|
|
34
|
-
* and dynamic re-tracking is not needed.
|
|
35
|
-
* Returns a disposer that removes the subscription.
|
|
36
|
-
*/
|
|
37
|
-
subscribe(listener: () => void): () => void
|
|
38
|
-
/**
|
|
39
|
-
* Register a direct updater — even lighter than subscribe().
|
|
40
|
-
* Intended for compiler-emitted DOM bindings (_bindText, _bindDirect).
|
|
41
|
-
* Returns a disposer that removes the updater (O(1)); the live set
|
|
42
|
-
* stays bounded under register/dispose churn.
|
|
43
|
-
*/
|
|
44
|
-
direct(updater: () => void): () => void
|
|
45
|
-
/**
|
|
46
|
-
* Debug name — useful for devtools and logging. Set via the `name` option at
|
|
47
|
-
* creation; can be reassigned at any time (`s.label = 'renamed'`) since it's
|
|
48
|
-
* stored as a regular own property on the signal function.
|
|
49
|
-
*/
|
|
50
|
-
label: string | undefined
|
|
51
|
-
/** Returns a snapshot of the signal's debug info (value, name, subscriber count). */
|
|
52
|
-
debug(): SignalDebugInfo<T>
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface SignalOptions {
|
|
56
|
-
/** Debug name for this signal — shows up in devtools and debug() output. */
|
|
57
|
-
name?: string
|
|
58
|
-
/**
|
|
59
|
-
* @internal — source location injected by `@pyreon/vite-plugin` at build
|
|
60
|
-
* time. When present, the runtime skips the `new Error().stack` capture
|
|
61
|
-
* in `_rdRegister` — saves ~2.2µs per signal creation when devtools is
|
|
62
|
-
* active. Plain user code should NOT set this; the field is opaque
|
|
63
|
-
* (no public type) so it's not part of the public API surface.
|
|
64
|
-
*
|
|
65
|
-
* Shape: `{ file: string; line: number; col: number }` matching
|
|
66
|
-
* `@pyreon/reactivity`'s `SourceLocation`.
|
|
67
|
-
*/
|
|
68
|
-
__sourceLocation?: { file: string; line: number; col: number }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Internal shape of a signal function — state stored as properties on the
|
|
72
|
-
// function object so methods can be shared via assignment (not per-signal closures).
|
|
73
|
-
interface SignalFn<T> {
|
|
74
|
-
(): T
|
|
75
|
-
/** @internal current value */
|
|
76
|
-
_v: T
|
|
77
|
-
/** @internal subscriber set (lazily allocated by trackSubscriber) */
|
|
78
|
-
_s: Set<() => void> | null
|
|
79
|
-
/** @internal direct updater set — compiler-emitted DOM updaters (lazily allocated) */
|
|
80
|
-
_d: Set<() => void> | null
|
|
81
|
-
peek(): T
|
|
82
|
-
set(value: T): void
|
|
83
|
-
update(fn: (current: T) => T): void
|
|
84
|
-
subscribe(listener: () => void): () => void
|
|
85
|
-
/** Register a direct updater — lighter than subscribe; O(1) set-based disposal. */
|
|
86
|
-
direct(updater: () => void): () => void
|
|
87
|
-
label: string | undefined
|
|
88
|
-
debug(): SignalDebugInfo<T>
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Shared method implementations — defined once, assigned to every signal.
|
|
92
|
-
// Uses `this` binding (signal methods are always called as `signal.method()`).
|
|
93
|
-
function _peek(this: SignalFn<unknown>) {
|
|
94
|
-
return this._v
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function _set(this: SignalFn<unknown>, newValue: unknown) {
|
|
98
|
-
if (Object.is(this._v, newValue)) return
|
|
99
|
-
if (process.env.NODE_ENV !== 'production')
|
|
100
|
-
_countSink.__pyreon_count__?.('reactivity.signalWrite')
|
|
101
|
-
const prev = this._v
|
|
102
|
-
this._v = newValue
|
|
103
|
-
// Dev-only bounded ring buffer of recent writes — attached to error
|
|
104
|
-
// reports so a crash carries the causal sequence of signal changes,
|
|
105
|
-
// not just the thrown value. Tree-shaken in prod via the gate.
|
|
106
|
-
// Deliberately separate from the `isTracing()` path below: that one
|
|
107
|
-
// is opt-in (requires an onSignalUpdate listener) and captures a
|
|
108
|
-
// stack (expensive); this is always-on in dev and intentionally
|
|
109
|
-
// cheap (string preview, no stack).
|
|
110
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
111
|
-
_recordSignalWrite(this.label, prev, newValue)
|
|
112
|
-
_rdRecordFire(this)
|
|
113
|
-
}
|
|
114
|
-
if (isTracing()) {
|
|
115
|
-
// Trace listeners are user-supplied debug code that fires on every
|
|
116
|
-
// signal write. A throwing listener here would leave `_v` updated but
|
|
117
|
-
// subscribers never notified (state divergence: readers see the new
|
|
118
|
-
// value, but no effects run). Trace failures must not corrupt program
|
|
119
|
-
// state — wrap in try/catch and route through `_userErrorHandler` so
|
|
120
|
-
// the corruption is at least visible. Listeners are removed via the
|
|
121
|
-
// disposer returned by `onSignalUpdate`; this catch prevents one bad
|
|
122
|
-
// listener from breaking unrelated reactive flow.
|
|
123
|
-
try {
|
|
124
|
-
_notifyTraceListeners(this as unknown as Signal<unknown>, prev, newValue)
|
|
125
|
-
} catch (err) {
|
|
126
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
127
|
-
// oxlint-disable-next-line no-console
|
|
128
|
-
console.error(
|
|
129
|
-
'[pyreon] signal trace listener threw — listener is buggy. Subscribers continue uninterrupted.',
|
|
130
|
-
err,
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
// Auto-batch the notification chain. Without this, a diamond dependency
|
|
136
|
-
// graph (a → b, c → d → effect) fires the apex effect TWICE per write
|
|
137
|
-
// because subscribers cascade inline: the first path through `b` reaches
|
|
138
|
-
// `effect`, whose read clears `d`'s dirty flag; then `c`'s notification
|
|
139
|
-
// re-dirties `d` and re-notifies `effect`. Wrapping the notify chain in
|
|
140
|
-
// `batch()` routes cascade-notifications through the pending Set, which
|
|
141
|
-
// dedupes on `d.recompute` and on `effect.run`.
|
|
142
|
-
//
|
|
143
|
-
// The batch is synchronous — observable behaviour is unchanged for the
|
|
144
|
-
// common case (subscribers still fire immediately after the write). Only
|
|
145
|
-
// the dedup semantics change, which is a bug fix.
|
|
146
|
-
//
|
|
147
|
-
// Short-circuit when already inside a batch so we don't wrap redundantly.
|
|
148
|
-
if (isBatching()) {
|
|
149
|
-
if (this._d) notifyDirect(this._d)
|
|
150
|
-
if (this._s) notifySubscribers(this._s)
|
|
151
|
-
} else {
|
|
152
|
-
batch(() => {
|
|
153
|
-
if (this._d) notifyDirect(this._d)
|
|
154
|
-
if (this._s) notifySubscribers(this._s)
|
|
155
|
-
})
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function _update(this: SignalFn<unknown>, fn: (current: unknown) => unknown) {
|
|
160
|
-
_set.call(this, fn(this._v))
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _subscribe(this: SignalFn<unknown>, listener: () => void): () => void {
|
|
164
|
-
if (!this._s) this._s = new Set()
|
|
165
|
-
this._s.add(listener)
|
|
166
|
-
return () => this._s?.delete(listener)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Register a direct updater — lighter than subscribe().
|
|
171
|
-
* Used by compiler-emitted _bindText/_bindDirect for zero-overhead DOM bindings.
|
|
172
|
-
*
|
|
173
|
-
* Backed by a `Set` (same as `_s`), NOT a flat array. The array form
|
|
174
|
-
* disposed by nulling the slot (`arr[idx] = null`) but never compacted —
|
|
175
|
-
* so a long-lived signal (theme/locale/auth, or a signal read inside
|
|
176
|
-
* `<For>` rows) bound by churning components accumulated one permanent
|
|
177
|
-
* dead slot per ever-mounted binding. That is an app-lifetime memory
|
|
178
|
-
* leak AND degrades the signal-write hot path: `notifyDirect` iterated
|
|
179
|
-
* O(total-ever-registered), not O(live). A Set bounds growth to the live
|
|
180
|
-
* set and keeps disposal + iteration O(live); the "Set.delete overhead"
|
|
181
|
-
* the array form optimised for is negligible against an unbounded array.
|
|
182
|
-
*/
|
|
183
|
-
function _directFn(this: SignalFn<unknown>, updater: () => void): () => void {
|
|
184
|
-
if (!this._d) this._d = new Set()
|
|
185
|
-
const set = this._d
|
|
186
|
-
set.add(updater)
|
|
187
|
-
return () => {
|
|
188
|
-
set.delete(updater)
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Notify direct updaters — set iteration, batch-aware. Disposed updaters
|
|
194
|
-
* are already absent from the set (O(1) delete on disposal).
|
|
195
|
-
*/
|
|
196
|
-
function notifyDirect(updaters: Set<() => void>): void {
|
|
197
|
-
if (isBatching()) {
|
|
198
|
-
for (const fn of updaters) enqueuePendingNotification(fn)
|
|
199
|
-
} else {
|
|
200
|
-
for (const fn of updaters) fn()
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function _debug(this: SignalFn<unknown>): SignalDebugInfo<unknown> {
|
|
205
|
-
return {
|
|
206
|
-
name: this.label,
|
|
207
|
-
value: this._v,
|
|
208
|
-
subscriberCount: this._s?.size ?? 0,
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Create a reactive signal.
|
|
214
|
-
*
|
|
215
|
-
* Only 1 closure is allocated (the read function). State is stored as
|
|
216
|
-
* properties on the function object (_v, _s) and methods (peek, set,
|
|
217
|
-
* update, subscribe) are shared across all signals — not per-signal closures.
|
|
218
|
-
*/
|
|
219
|
-
export function signal<T>(initialValue: T, options?: SignalOptions): Signal<T> {
|
|
220
|
-
if (process.env.NODE_ENV !== 'production')
|
|
221
|
-
_countSink.__pyreon_count__?.('reactivity.signalCreate')
|
|
222
|
-
// The read function is the only per-signal closure.
|
|
223
|
-
// It doubles as the SubscriberHost (_s property) for trackSubscriber.
|
|
224
|
-
const read = ((...args: unknown[]) => {
|
|
225
|
-
if (process.env.NODE_ENV !== 'production' && args.length > 0) {
|
|
226
|
-
// oxlint-disable-next-line no-console
|
|
227
|
-
console.warn(
|
|
228
|
-
'[Pyreon] signal() was called with an argument. ' +
|
|
229
|
-
'Use signal.set(value) or signal.update(fn) to write. ' +
|
|
230
|
-
'signal(value) only reads — the argument is ignored.',
|
|
231
|
-
)
|
|
232
|
-
}
|
|
233
|
-
trackSubscriber(read as SignalFn<T>)
|
|
234
|
-
return read._v
|
|
235
|
-
}) as unknown as SignalFn<T>
|
|
236
|
-
|
|
237
|
-
read._v = initialValue
|
|
238
|
-
read._s = null
|
|
239
|
-
read._d = null
|
|
240
|
-
read.peek = _peek as () => T
|
|
241
|
-
read.set = _set as (value: T) => void
|
|
242
|
-
read.update = _update as (fn: (current: T) => T) => void
|
|
243
|
-
read.subscribe = _subscribe as (listener: () => void) => () => void
|
|
244
|
-
read.direct = _directFn as (updater: () => void) => () => void
|
|
245
|
-
read.debug = _debug as () => SignalDebugInfo<T>
|
|
246
|
-
read.label = options?.name
|
|
247
|
-
|
|
248
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
249
|
-
// Prefer build-time-injected location (zero runtime cost) over the
|
|
250
|
-
// ~2.2µs stack-capture fallback. @pyreon/vite-plugin's
|
|
251
|
-
// `injectSignalLocations` rewrites `signal(0)` to
|
|
252
|
-
// `signal(0, { __sourceLocation: {...} })` at transform time so most
|
|
253
|
-
// dev-mode signals never pay the stack-capture cost.
|
|
254
|
-
const loc = options?.__sourceLocation
|
|
255
|
-
? options.__sourceLocation
|
|
256
|
-
: _captureCallerLocation(1)
|
|
257
|
-
_rdRegister(read, 'signal', read, null, read.label, loc)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return read as unknown as Signal<T>
|
|
261
|
-
}
|