@slimlib/store 1.6.2 → 2.0.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 +700 -129
- package/dist/index.mjs +1067 -1
- package/dist/index.mjs.map +1 -0
- package/package.json +9 -69
- package/src/computed.ts +232 -0
- package/src/core.ts +434 -0
- package/src/debug.ts +115 -0
- package/src/effect.ts +125 -0
- package/src/flags.ts +38 -0
- package/src/globals.ts +30 -0
- package/src/index.ts +9 -0
- package/src/internal-types.ts +45 -0
- package/src/scope.ts +85 -0
- package/src/signal.ts +55 -0
- package/src/state.ts +170 -0
- package/src/symbols.ts +9 -0
- package/src/types.ts +47 -0
- package/types/index.d.ts +129 -0
- package/types/index.d.ts.map +52 -0
- package/angular/package.json +0 -5
- package/core/package.json +0 -5
- package/dist/angular.cjs +0 -37
- package/dist/angular.d.ts +0 -23
- package/dist/angular.mjs +0 -33
- package/dist/angular.umd.js +0 -2
- package/dist/angular.umd.js.map +0 -1
- package/dist/core.cjs +0 -79
- package/dist/core.d.ts +0 -8
- package/dist/core.mjs +0 -76
- package/dist/index.cjs +0 -8
- package/dist/index.d.ts +0 -1
- package/dist/index.umd.js +0 -2
- package/dist/index.umd.js.map +0 -1
- package/dist/preact.cjs +0 -16
- package/dist/preact.d.ts +0 -3
- package/dist/preact.mjs +0 -13
- package/dist/preact.umd.js +0 -2
- package/dist/preact.umd.js.map +0 -1
- package/dist/react.cjs +0 -16
- package/dist/react.d.ts +0 -3
- package/dist/react.mjs +0 -13
- package/dist/react.umd.js +0 -2
- package/dist/react.umd.js.map +0 -1
- package/dist/rxjs.cjs +0 -18
- package/dist/rxjs.d.ts +0 -3
- package/dist/rxjs.mjs +0 -15
- package/dist/rxjs.umd.js +0 -2
- package/dist/rxjs.umd.js.map +0 -1
- package/dist/svelte.cjs +0 -7
- package/dist/svelte.d.ts +0 -1
- package/dist/svelte.mjs +0 -5
- package/dist/svelte.umd.js +0 -2
- package/dist/svelte.umd.js.map +0 -1
- package/preact/package.json +0 -5
- package/react/package.json +0 -5
- package/rxjs/package.json +0 -5
- package/svelte/package.json +0 -5
package/src/computed.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkComputedSources,
|
|
3
|
+
clearSources,
|
|
4
|
+
createSourceEntry,
|
|
5
|
+
currentComputing,
|
|
6
|
+
DepsSet,
|
|
7
|
+
globalVersion,
|
|
8
|
+
makeLive,
|
|
9
|
+
noopGetter,
|
|
10
|
+
runWithTracking,
|
|
11
|
+
setTracked,
|
|
12
|
+
tracked,
|
|
13
|
+
} from './core';
|
|
14
|
+
import { cycleMessage } from './debug';
|
|
15
|
+
import { Flag } from './flags';
|
|
16
|
+
import type { ReactiveNode, SourceEntry } from './internal-types';
|
|
17
|
+
import type { Computed } from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read function for computed nodes
|
|
21
|
+
*/
|
|
22
|
+
export function computedRead<T>(self: ReactiveNode): T {
|
|
23
|
+
// ===== PULL PHASE: Register this computed as a dependency of the current consumer =====
|
|
24
|
+
// Track if someone is reading us
|
|
25
|
+
if (tracked && currentComputing !== undefined) {
|
|
26
|
+
// Inline tracking for computed dependencies
|
|
27
|
+
const consumerSources = currentComputing.$_sources;
|
|
28
|
+
const skipIndex = currentComputing.$_skipped;
|
|
29
|
+
const deps = self.$_deps;
|
|
30
|
+
const existing = consumerSources[skipIndex];
|
|
31
|
+
const noSource = existing === undefined;
|
|
32
|
+
|
|
33
|
+
if (noSource || existing.$_dependents !== deps) {
|
|
34
|
+
// Different dependency - clear old ones from this point and rebuild
|
|
35
|
+
if (!noSource) {
|
|
36
|
+
clearSources(currentComputing, skipIndex);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Push source entry - version will be updated after source computes
|
|
40
|
+
// Uses shared createSourceEntry factory for V8 hidden class monomorphism
|
|
41
|
+
consumerSources.push(createSourceEntry(deps as DepsSet<ReactiveNode>, self, 0, undefined, undefined));
|
|
42
|
+
|
|
43
|
+
// Only register with source if we're live
|
|
44
|
+
if ((currentComputing.$_flags & (Flag.EFFECT | Flag.LIVE)) !== 0) {
|
|
45
|
+
(deps as DepsSet<ReactiveNode>).add(currentComputing);
|
|
46
|
+
// If source computed is not live, make it live
|
|
47
|
+
if ((self.$_flags & Flag.LIVE) === 0) {
|
|
48
|
+
makeLive(self);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Mark that this node has computed sources (for version update loop optimization)
|
|
53
|
+
currentComputing.$_flags |= Flag.HAS_COMPUTED_SOURCE;
|
|
54
|
+
++currentComputing.$_skipped;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let flags = self.$_flags;
|
|
58
|
+
const sourcesArray = self.$_sources;
|
|
59
|
+
|
|
60
|
+
// Cycle detection: if this computed is already being computed, we have a cycle
|
|
61
|
+
// This matches TC39 Signals proposal behavior: throw an error on cyclic reads
|
|
62
|
+
if ((flags & Flag.COMPUTING) !== 0) {
|
|
63
|
+
throw new Error(cycleMessage);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ===== PULL PHASE: Check if cached value can be returned =====
|
|
67
|
+
// Combined check: node has cached result and doesn't need work
|
|
68
|
+
const hasCached = (flags & (Flag.HAS_VALUE | Flag.HAS_ERROR)) !== 0;
|
|
69
|
+
|
|
70
|
+
// biome-ignore lint/suspicious/noConfusingLabels: expected
|
|
71
|
+
checkCache: if ((flags & (Flag.DIRTY | Flag.CHECK)) === 0 && hasCached) {
|
|
72
|
+
// Fast-path: nothing has changed globally since last read
|
|
73
|
+
if (self.$_stamp === globalVersion) {
|
|
74
|
+
if ((flags & Flag.HAS_ERROR) !== 0) {
|
|
75
|
+
throw self.$_value;
|
|
76
|
+
}
|
|
77
|
+
return self.$_value as T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ===== PULL PHASE: Poll sources for non-live computeds =====
|
|
81
|
+
// Non-live nodes poll instead of receiving push notifications
|
|
82
|
+
if ((flags & Flag.LIVE) === 0) {
|
|
83
|
+
let sourceChanged = false;
|
|
84
|
+
|
|
85
|
+
// Disable tracking while polling sources to avoid unnecessary dependency tracking
|
|
86
|
+
const prevTracked = tracked;
|
|
87
|
+
// TODO: inline after it combined to a single scope?
|
|
88
|
+
setTracked(false);
|
|
89
|
+
for (let i = 0, len = sourcesArray.length; i < len; ++i) {
|
|
90
|
+
const source = sourcesArray[i] as SourceEntry;
|
|
91
|
+
const sourceNode = source.$_node; // Extract once at loop start
|
|
92
|
+
if (sourceNode === undefined) {
|
|
93
|
+
// State source - check if deps version changed
|
|
94
|
+
const currentDepsVersion = (source.$_dependents as DepsSet<ReactiveNode>).$_version as number;
|
|
95
|
+
if (source.$_version !== currentDepsVersion) {
|
|
96
|
+
// Deps version changed, check if actual value reverted (primitives only)
|
|
97
|
+
const storedValue = source.$_storedValue;
|
|
98
|
+
const storedType = typeof storedValue;
|
|
99
|
+
if (storedValue === null || (storedType !== 'object' && storedType !== 'function')) {
|
|
100
|
+
const currentValue = (source.$_getter as () => unknown)();
|
|
101
|
+
if (Object.is(currentValue, storedValue)) {
|
|
102
|
+
// Value reverted - update depsVersion and continue checking
|
|
103
|
+
source.$_version = currentDepsVersion;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Value actually changed - mark DIRTY and skip remaining
|
|
108
|
+
sourceChanged = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// Computed source - use sourceNode directly (already extracted above)
|
|
113
|
+
try {
|
|
114
|
+
computedRead(sourceNode);
|
|
115
|
+
} catch {
|
|
116
|
+
// Error counts as changed
|
|
117
|
+
sourceChanged = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
if (source.$_version !== sourceNode.$_version) {
|
|
121
|
+
sourceChanged = true;
|
|
122
|
+
break; // EXIT EARLY - don't process remaining sources
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
setTracked(prevTracked);
|
|
127
|
+
|
|
128
|
+
if (sourceChanged) {
|
|
129
|
+
// Source changed or threw - mark DIRTY and proceed to recompute
|
|
130
|
+
self.$_flags = flags |= Flag.DIRTY;
|
|
131
|
+
break checkCache;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// No sources changed - return cached value
|
|
135
|
+
self.$_stamp = globalVersion;
|
|
136
|
+
if ((flags & Flag.HAS_ERROR) !== 0) {
|
|
137
|
+
throw self.$_value;
|
|
138
|
+
}
|
|
139
|
+
return self.$_value as T;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ===== PULL PHASE: Verify CHECK state for live computeds =====
|
|
144
|
+
// Live computeds receive CHECK via push - verify sources before recomputing
|
|
145
|
+
// Non-live computeds already verified above during polling
|
|
146
|
+
// Note: Check for Flag.HAS_VALUE OR Flag.HAS_ERROR since cached errors should also use this path
|
|
147
|
+
if ((flags & (Flag.DIRTY | Flag.CHECK | Flag.HAS_STATE_SOURCE)) === Flag.CHECK && hasCached) {
|
|
148
|
+
if (checkComputedSources(sourcesArray)) {
|
|
149
|
+
// Sources changed or errored - mark DIRTY and let getter run
|
|
150
|
+
flags = (flags & ~Flag.CHECK) | Flag.DIRTY;
|
|
151
|
+
} else {
|
|
152
|
+
// Sources unchanged, clear CHECK flag and return cached value
|
|
153
|
+
self.$_flags = flags & ~Flag.CHECK;
|
|
154
|
+
// No sources changed - return cached value
|
|
155
|
+
self.$_stamp = globalVersion;
|
|
156
|
+
if ((flags & Flag.HAS_ERROR) !== 0) {
|
|
157
|
+
throw self.$_value;
|
|
158
|
+
}
|
|
159
|
+
return self.$_value as T;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ===== PULL PHASE: Recompute value by pulling from sources =====
|
|
164
|
+
// Recompute if dirty or check (sources actually changed)
|
|
165
|
+
if ((flags & (Flag.DIRTY | Flag.CHECK)) !== 0) {
|
|
166
|
+
const wasDirty = (flags & Flag.DIRTY) !== 0;
|
|
167
|
+
|
|
168
|
+
runWithTracking(self, () => {
|
|
169
|
+
try {
|
|
170
|
+
const newValue = (self.$_fn as () => T)();
|
|
171
|
+
|
|
172
|
+
// Check if value actually changed (common path: no error recovery)
|
|
173
|
+
const changed = (flags & Flag.HAS_VALUE) === 0 || !(self.$_equals as (a: T, b: T) => boolean)(self.$_value as T, newValue);
|
|
174
|
+
|
|
175
|
+
if (changed) {
|
|
176
|
+
self.$_value = newValue;
|
|
177
|
+
// Increment version to indicate value changed (for polling)
|
|
178
|
+
++self.$_version;
|
|
179
|
+
self.$_flags = (self.$_flags | Flag.HAS_VALUE) & ~Flag.HAS_ERROR;
|
|
180
|
+
// ===== PUSH PHASE (during pull): Mark CHECK-only dependents as DIRTY =====
|
|
181
|
+
// When value changes during recomputation, upgrade dependent CHECK flags to DIRTY
|
|
182
|
+
for (const dep of self.$_deps) {
|
|
183
|
+
const depFlags = dep.$_flags;
|
|
184
|
+
if ((depFlags & (Flag.COMPUTING | Flag.DIRTY | Flag.CHECK)) === Flag.CHECK) {
|
|
185
|
+
dep.$_flags = depFlags | Flag.DIRTY;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} else if (wasDirty) {
|
|
189
|
+
self.$_flags |= Flag.HAS_VALUE;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Update last seen global version
|
|
193
|
+
self.$_stamp = globalVersion;
|
|
194
|
+
} catch (e) {
|
|
195
|
+
// Per TC39 Signals proposal: cache the error and mark as clean with error flag
|
|
196
|
+
// The error will be rethrown on subsequent reads until a dependency changes
|
|
197
|
+
// Reuse valueSymbol for error storage since a computed can't have both value and error
|
|
198
|
+
// Increment version since the result changed (to error)
|
|
199
|
+
++self.$_version;
|
|
200
|
+
self.$_value = e as T;
|
|
201
|
+
self.$_flags = (self.$_flags & ~Flag.HAS_VALUE) | Flag.HAS_ERROR;
|
|
202
|
+
self.$_stamp = globalVersion;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check if we have a cached error to rethrow (stored in valueSymbol)
|
|
208
|
+
if ((self.$_flags & Flag.HAS_ERROR) !== 0) {
|
|
209
|
+
throw self.$_value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return self.$_value as T;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Creates a computed value that automatically tracks dependencies and caches results
|
|
217
|
+
*/
|
|
218
|
+
export const computed = <T>(getter: () => T, equals: (a: T, b: T) => boolean = Object.is): Computed<T> => {
|
|
219
|
+
const node = {
|
|
220
|
+
$_sources: [],
|
|
221
|
+
$_deps: new DepsSet<ReactiveNode>(noopGetter),
|
|
222
|
+
$_flags: Flag.DIRTY,
|
|
223
|
+
$_skipped: 0,
|
|
224
|
+
$_version: 0,
|
|
225
|
+
$_value: undefined,
|
|
226
|
+
$_stamp: 0,
|
|
227
|
+
$_fn: getter,
|
|
228
|
+
$_equals: equals,
|
|
229
|
+
} as ReactiveNode;
|
|
230
|
+
|
|
231
|
+
return () => computedRead(node);
|
|
232
|
+
};
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core reactive system implementation using push/pull algorithm:
|
|
3
|
+
*
|
|
4
|
+
* PUSH PHASE: When a source value changes (signal/state write):
|
|
5
|
+
* - Increment globalVersion
|
|
6
|
+
* - Eagerly propagate CHECK flags to all dependents
|
|
7
|
+
* - Schedule effects for execution
|
|
8
|
+
*
|
|
9
|
+
* PULL PHASE: When a computed/effect is read:
|
|
10
|
+
* - Check if recomputation is needed (via flags and source versions)
|
|
11
|
+
* - Lazily recompute by pulling values from sources
|
|
12
|
+
* - Update cached value and version
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { computedRead } from './computed';
|
|
16
|
+
import { Flag } from './flags';
|
|
17
|
+
import { scheduler } from './globals';
|
|
18
|
+
import { unwrap } from './symbols';
|
|
19
|
+
import type { ReactiveNode, SourceEntry } from './internal-types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* No-op getter used as default for DepsSet.$_getter to ensure all DepsSet
|
|
23
|
+
* instances have the same field representation (function, never undefined).
|
|
24
|
+
* This avoids V8 field representation deopts when some DepsSets have a getter
|
|
25
|
+
* and others don't.
|
|
26
|
+
*/
|
|
27
|
+
/* v8 ignore next -- @preserve */
|
|
28
|
+
export const noopGetter = (): unknown => undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* DepsSet extends Set with $_version and $_getter properties for dependency
|
|
32
|
+
* tracking. Using a class ensures all instances share the same V8 hidden class,
|
|
33
|
+
* avoiding hidden class transitions and field constness changes.
|
|
34
|
+
*
|
|
35
|
+
* IMPORTANT: $_getter always defaults to a no-op function (never undefined)
|
|
36
|
+
* so that all DepsSet instances share the same V8 field representation.
|
|
37
|
+
* This prevents "dependent field representation changed" deopts.
|
|
38
|
+
*/
|
|
39
|
+
export class DepsSet<T> extends Set<T> {
|
|
40
|
+
$_version = 0;
|
|
41
|
+
$_getter: () => unknown;
|
|
42
|
+
constructor(getter: () => unknown) {
|
|
43
|
+
super();
|
|
44
|
+
this.$_getter = getter;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Factory for creating source entries (unified allocation site).
|
|
50
|
+
*
|
|
51
|
+
* Both state and computed source entries MUST be created through this single
|
|
52
|
+
* factory to ensure they share the same V8 hidden class. Using separate
|
|
53
|
+
* object literals in different functions creates different allocation sites,
|
|
54
|
+
* causing V8 to assign different hidden classes and making all property
|
|
55
|
+
* accesses on source entries polymorphic.
|
|
56
|
+
*
|
|
57
|
+
* @param dependents - The DepsSet tracking dependents of this source
|
|
58
|
+
* @param node - The source ReactiveNode (undefined for state/signal sources)
|
|
59
|
+
* @param version - Initial version number
|
|
60
|
+
* @param getter - Value getter (undefined for computed sources)
|
|
61
|
+
* @param storedValue - Cached value (undefined for computed sources)
|
|
62
|
+
*/
|
|
63
|
+
export const createSourceEntry = (
|
|
64
|
+
dependents: DepsSet<ReactiveNode>,
|
|
65
|
+
node: ReactiveNode | undefined,
|
|
66
|
+
version: number,
|
|
67
|
+
getter: undefined | (() => unknown),
|
|
68
|
+
storedValue: unknown
|
|
69
|
+
): SourceEntry => ({
|
|
70
|
+
$_dependents: dependents,
|
|
71
|
+
$_node: node,
|
|
72
|
+
$_version: version,
|
|
73
|
+
$_getter: getter,
|
|
74
|
+
$_storedValue: storedValue,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let flushScheduled = false;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Global version counter - increments on every signal/state write
|
|
81
|
+
* Used for fast-path: if globalVersion hasn't changed since last read, skip all checks
|
|
82
|
+
*/
|
|
83
|
+
export let globalVersion = 1;
|
|
84
|
+
|
|
85
|
+
export const batched: ReactiveNode[] = [];
|
|
86
|
+
|
|
87
|
+
let lastAddedId = 0;
|
|
88
|
+
|
|
89
|
+
let needsSort = false;
|
|
90
|
+
|
|
91
|
+
// Computation tracking state
|
|
92
|
+
export let currentComputing: ReactiveNode | undefined;
|
|
93
|
+
export let tracked = true;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Set the tracked state for dependency tracking (internal use only)
|
|
97
|
+
*/
|
|
98
|
+
export const setTracked = (value: boolean) => {
|
|
99
|
+
tracked = value;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Add an effect to the batched set
|
|
104
|
+
* PUSH PHASE: Part of effect scheduling during dirty propagation
|
|
105
|
+
* Caller must check Flag.NEEDS_WORK before calling to avoid duplicates
|
|
106
|
+
*/
|
|
107
|
+
export const batchedAdd = (node: ReactiveNode): void => {
|
|
108
|
+
const nodeId = node.$_stamp;
|
|
109
|
+
// Track if we're adding out of order
|
|
110
|
+
if (nodeId < lastAddedId) {
|
|
111
|
+
needsSort = true;
|
|
112
|
+
}
|
|
113
|
+
lastAddedId = nodeId;
|
|
114
|
+
batched.push(node);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Add a newly created effect to the batched set
|
|
119
|
+
* Used during effect creation - new effects always have the highest ID
|
|
120
|
+
* so we unconditionally update lastAddedId without checking order
|
|
121
|
+
*/
|
|
122
|
+
export const batchedAddNew = (node: ReactiveNode, effectId: number): void => {
|
|
123
|
+
lastAddedId = effectId;
|
|
124
|
+
batched.push(node);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Unwraps a proxied value to get the underlying object
|
|
129
|
+
* (Utility - not specific to push/pull phases)
|
|
130
|
+
*/
|
|
131
|
+
export const unwrapValue = <T>(value: T): T =>
|
|
132
|
+
(value !== null &&
|
|
133
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
134
|
+
((value as unknown as Record<symbol, unknown>)[unwrap] as T)) ||
|
|
135
|
+
value;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Make a computed live - register it with all its sources
|
|
139
|
+
* Called when a live consumer starts reading this computed
|
|
140
|
+
* PUSH PHASE: Enables push notifications to flow through this node
|
|
141
|
+
*/
|
|
142
|
+
export const makeLive = (node: ReactiveNode): void => {
|
|
143
|
+
node.$_flags |= Flag.LIVE;
|
|
144
|
+
const nodeSources = node.$_sources;
|
|
145
|
+
for (let i = 0, len = nodeSources.length; i < len; ++i) {
|
|
146
|
+
const { $_dependents, $_node: sourceNode } = nodeSources[i] as SourceEntry;
|
|
147
|
+
$_dependents.add(node);
|
|
148
|
+
if (sourceNode !== undefined && (sourceNode.$_flags & (Flag.EFFECT | Flag.LIVE)) === 0) {
|
|
149
|
+
makeLive(sourceNode);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Make a computed non-live - unregister it from all its sources
|
|
156
|
+
* Called when all live consumers stop depending on this computed
|
|
157
|
+
* PUSH PHASE: Disables push notifications through this node
|
|
158
|
+
*/
|
|
159
|
+
export const makeNonLive = (node: ReactiveNode): void => {
|
|
160
|
+
node.$_flags &= ~Flag.LIVE;
|
|
161
|
+
const nodeSources = node.$_sources;
|
|
162
|
+
for (let i = 0, len = nodeSources.length; i < len; ++i) {
|
|
163
|
+
const { $_dependents, $_node: sourceNode } = nodeSources[i] as SourceEntry;
|
|
164
|
+
$_dependents.delete(node);
|
|
165
|
+
// Check: has Flag.LIVE but not Flag.EFFECT (effects never become non-live)
|
|
166
|
+
if (
|
|
167
|
+
sourceNode !== undefined &&
|
|
168
|
+
(sourceNode.$_flags & (Flag.EFFECT | Flag.LIVE)) === Flag.LIVE &&
|
|
169
|
+
(sourceNode.$_deps as Set<ReactiveNode>).size === 0
|
|
170
|
+
) {
|
|
171
|
+
makeNonLive(sourceNode);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Clear sources for a node starting from a specific index
|
|
178
|
+
* PULL PHASE: Cleanup during dependency tracking when sources change
|
|
179
|
+
*/
|
|
180
|
+
export const clearSources = (node: ReactiveNode, fromIndex = 0): void => {
|
|
181
|
+
const isLive = (node.$_flags & (Flag.EFFECT | Flag.LIVE)) !== 0;
|
|
182
|
+
const nodeSources = node.$_sources;
|
|
183
|
+
|
|
184
|
+
for (let i = fromIndex, len = nodeSources.length; i < len; ++i) {
|
|
185
|
+
const { $_dependents, $_node: sourceNode } = nodeSources[i] as SourceEntry;
|
|
186
|
+
|
|
187
|
+
// Check if this deps is retained (exists in kept portion) - avoid removing shared deps
|
|
188
|
+
let retained = false;
|
|
189
|
+
for (let j = 0; j < fromIndex && !retained; ++j) {
|
|
190
|
+
retained = (nodeSources[j] as SourceEntry).$_dependents === $_dependents;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!retained) {
|
|
194
|
+
// Always remove from deps to prevent stale notifications
|
|
195
|
+
$_dependents.delete(node);
|
|
196
|
+
// If source is a computed and we're live, check if it became non-live
|
|
197
|
+
if (
|
|
198
|
+
isLive &&
|
|
199
|
+
sourceNode !== undefined &&
|
|
200
|
+
(sourceNode.$_flags & (Flag.EFFECT | Flag.LIVE)) === Flag.LIVE &&
|
|
201
|
+
(sourceNode.$_deps as Set<ReactiveNode>).size === 0
|
|
202
|
+
) {
|
|
203
|
+
makeNonLive(sourceNode);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
nodeSources.length = fromIndex;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Execute all pending effects immediately
|
|
212
|
+
* This function can be called to manually trigger all scheduled effects
|
|
213
|
+
* before the next microtask
|
|
214
|
+
* PULL PHASE: Executes batched effects, each effect pulls its dependencies
|
|
215
|
+
*/
|
|
216
|
+
export const flushEffects = (): void => {
|
|
217
|
+
flushScheduled = false;
|
|
218
|
+
// Collect nodes, only sort if effects were added out of order
|
|
219
|
+
// Use a copy of the array for execution to allow re-scheduling
|
|
220
|
+
const nodes = batched.slice();
|
|
221
|
+
batched.length = 0;
|
|
222
|
+
|
|
223
|
+
if (needsSort) {
|
|
224
|
+
nodes.sort((a, b) => a.$_stamp - b.$_stamp);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
lastAddedId = 0;
|
|
228
|
+
needsSort = false;
|
|
229
|
+
|
|
230
|
+
// Call $_fn on each node instead of calling nodes directly as functions
|
|
231
|
+
// This enables effect nodes to be plain objects (same hidden class as computed)
|
|
232
|
+
for (let i = 0, len = nodes.length; i < len; ++i) {
|
|
233
|
+
const node = nodes[i] as ReactiveNode;
|
|
234
|
+
try {
|
|
235
|
+
node.$_fn?.();
|
|
236
|
+
} catch (e) {
|
|
237
|
+
console.error(e);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Schedule flush via scheduler (default: microtask)
|
|
244
|
+
* PUSH PHASE: Schedules the transition from push to pull phase
|
|
245
|
+
*/
|
|
246
|
+
export const scheduleFlush = (): void => {
|
|
247
|
+
if (!flushScheduled) {
|
|
248
|
+
flushScheduled = true;
|
|
249
|
+
scheduler(flushEffects);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Track a state/signal dependency between currentComputing and a deps Set
|
|
255
|
+
* Uses liveness tracking - only live consumers register with sources
|
|
256
|
+
* PULL PHASE: Records dependencies during computation for future invalidation
|
|
257
|
+
*/
|
|
258
|
+
export const trackStateDependency = <T>(deps: DepsSet<ReactiveNode>, valueGetter: () => T, cachedValue: T): void => {
|
|
259
|
+
// Callers guarantee tracked && currentComputing are true
|
|
260
|
+
|
|
261
|
+
const sourcesArray = (currentComputing as ReactiveNode).$_sources;
|
|
262
|
+
const skipIndex = (currentComputing as ReactiveNode).$_skipped;
|
|
263
|
+
const existing = sourcesArray[skipIndex];
|
|
264
|
+
const noSource = existing === undefined;
|
|
265
|
+
|
|
266
|
+
if (noSource || existing.$_dependents !== deps) {
|
|
267
|
+
// Different dependency - clear old ones from this point and rebuild
|
|
268
|
+
if (!noSource) {
|
|
269
|
+
clearSources(currentComputing as ReactiveNode, skipIndex);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Track deps version, value getter, and last seen value for polling
|
|
273
|
+
// Uses shared createSourceEntry factory for V8 hidden class monomorphism
|
|
274
|
+
sourcesArray.push(
|
|
275
|
+
createSourceEntry(deps, undefined, (deps as DepsSet<ReactiveNode>).$_version as number, valueGetter, cachedValue)
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Mark that this node has state/signal sources (for polling optimization)
|
|
279
|
+
(currentComputing as ReactiveNode).$_flags |= Flag.HAS_STATE_SOURCE;
|
|
280
|
+
|
|
281
|
+
// Only register with source if we're live
|
|
282
|
+
if (((currentComputing as ReactiveNode).$_flags & (Flag.EFFECT | Flag.LIVE)) !== 0) {
|
|
283
|
+
deps.add(currentComputing as ReactiveNode);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
// Same state source - update depsVersion, getter, and storedValue for accurate polling
|
|
287
|
+
const entry = sourcesArray[skipIndex] as SourceEntry;
|
|
288
|
+
entry.$_version = (deps as DepsSet<ReactiveNode>).$_version as number;
|
|
289
|
+
entry.$_getter = valueGetter;
|
|
290
|
+
entry.$_storedValue = cachedValue;
|
|
291
|
+
// Re-set Flag.HAS_STATE_SOURCE (may have been cleared by runWithTracking)
|
|
292
|
+
(currentComputing as ReactiveNode).$_flags |= Flag.HAS_STATE_SOURCE;
|
|
293
|
+
}
|
|
294
|
+
++(currentComputing as ReactiveNode).$_skipped;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Mark a node as needing check (eager propagation with equality cutoff)
|
|
299
|
+
* PUSH PHASE: Eagerly propagates CHECK flag up the dependency graph
|
|
300
|
+
*/
|
|
301
|
+
export const markNeedsCheck = (node: ReactiveNode): void => {
|
|
302
|
+
const flags = node.$_flags;
|
|
303
|
+
// Fast path: skip if already computing, dirty, or marked CHECK
|
|
304
|
+
// (COMPUTING | DIRTY | CHECK) (bits 0, 1, 2)
|
|
305
|
+
if ((flags & (Flag.COMPUTING | Flag.DIRTY | Flag.CHECK)) !== 0) {
|
|
306
|
+
// Exception: computing effect that's not dirty yet needs to be marked dirty
|
|
307
|
+
// Uses combined mask: (flags & 13) === 12 means COMPUTING + EFFECT set, DIRTY not set
|
|
308
|
+
if ((flags & (Flag.COMPUTING | Flag.EFFECT | Flag.DIRTY)) === (Flag.COMPUTING | Flag.EFFECT)) {
|
|
309
|
+
node.$_flags = flags | Flag.DIRTY;
|
|
310
|
+
batchedAdd(node);
|
|
311
|
+
scheduleFlush();
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Not skipped: set CHECK and propagate to dependents
|
|
316
|
+
node.$_flags = flags | Flag.CHECK;
|
|
317
|
+
if ((flags & Flag.EFFECT) !== 0) {
|
|
318
|
+
batchedAdd(node);
|
|
319
|
+
scheduleFlush();
|
|
320
|
+
}
|
|
321
|
+
for (const dep of node.$_deps) {
|
|
322
|
+
markNeedsCheck(dep);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Mark all dependents in a Set as needing check
|
|
328
|
+
* PUSH PHASE: Entry point for push propagation when a source value changes
|
|
329
|
+
*/
|
|
330
|
+
export const markDependents = (deps: DepsSet<ReactiveNode>): void => {
|
|
331
|
+
++globalVersion;
|
|
332
|
+
// Increment deps version for non-live computed polling
|
|
333
|
+
++deps.$_version;
|
|
334
|
+
for (const dep of deps) {
|
|
335
|
+
markNeedsCheck(dep);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Run a getter function with dependency tracking
|
|
341
|
+
* Handles cycle detection, context setup, and source cleanup
|
|
342
|
+
* PULL PHASE: Core of pull - executes computation while tracking dependencies
|
|
343
|
+
*/
|
|
344
|
+
export const runWithTracking = <T>(node: ReactiveNode, getter: () => T): T => {
|
|
345
|
+
// Clear (DIRTY | CHECK) and source flags (will be recalculated during tracking)
|
|
346
|
+
// Note: Even when called from checkComputedSources (which sets tracked=false), this works
|
|
347
|
+
// because runWithTracking sets tracked=true, so trackDependency will re-set the flag
|
|
348
|
+
node.$_flags = (node.$_flags & ~(Flag.DIRTY | Flag.CHECK | Flag.HAS_STATE_SOURCE | Flag.HAS_COMPUTED_SOURCE)) | Flag.COMPUTING;
|
|
349
|
+
node.$_skipped = 0;
|
|
350
|
+
|
|
351
|
+
const prev = currentComputing;
|
|
352
|
+
const prevTracked = tracked;
|
|
353
|
+
currentComputing = node;
|
|
354
|
+
tracked = true;
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
return getter();
|
|
358
|
+
} finally {
|
|
359
|
+
currentComputing = prev;
|
|
360
|
+
tracked = prevTracked;
|
|
361
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
362
|
+
const flags = (node.$_flags &= ~Flag.COMPUTING);
|
|
363
|
+
const nodeSources = node.$_sources;
|
|
364
|
+
const skipped = node.$_skipped;
|
|
365
|
+
const nodeSourcesLength = nodeSources.length;
|
|
366
|
+
// Only update versions if there are computed sources (state sources update inline)
|
|
367
|
+
if ((flags & Flag.HAS_COMPUTED_SOURCE) !== 0) {
|
|
368
|
+
const updateLen = Math.min(skipped, nodeSourcesLength);
|
|
369
|
+
for (let i = 0; i < updateLen; ++i) {
|
|
370
|
+
const entry = nodeSources[i] as SourceEntry;
|
|
371
|
+
const entryNode = entry.$_node;
|
|
372
|
+
if (entryNode !== undefined) {
|
|
373
|
+
entry.$_version = entryNode.$_version;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Clean up any excess sources that weren't reused
|
|
378
|
+
if (nodeSourcesLength > skipped) {
|
|
379
|
+
clearSources(node, skipped);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Execute a callback without tracking any reactive dependencies
|
|
386
|
+
* Useful when reading signals/state without creating a dependency relationship
|
|
387
|
+
* PULL PHASE: Temporarily disables dependency tracking during pull
|
|
388
|
+
*/
|
|
389
|
+
export const untracked = <T>(callback: () => T): T => {
|
|
390
|
+
const prevTracked = tracked;
|
|
391
|
+
tracked = false;
|
|
392
|
+
try {
|
|
393
|
+
return callback();
|
|
394
|
+
} finally {
|
|
395
|
+
tracked = prevTracked;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Check if any computed sources have changed or errored.
|
|
401
|
+
* Used by CHECK path optimization in computed and effect.
|
|
402
|
+
* PULL PHASE: Verifies if sources actually changed before recomputing (equality cutoff)
|
|
403
|
+
*
|
|
404
|
+
* Note: Callers must check HAS_STATE_SOURCE flag before calling this function.
|
|
405
|
+
* This function assumes all sources are computed (have $_node).
|
|
406
|
+
*
|
|
407
|
+
* @param sourcesArray - The sources to check (must all be computed sources)
|
|
408
|
+
* @returns true if sources changed, false if unchanged
|
|
409
|
+
*/
|
|
410
|
+
export const checkComputedSources = (sourcesArray: SourceEntry[]): boolean => {
|
|
411
|
+
const prevTracked = tracked;
|
|
412
|
+
tracked = false;
|
|
413
|
+
const len = sourcesArray.length;
|
|
414
|
+
for (let i = 0; i < len; ++i) {
|
|
415
|
+
const sourceEntry = sourcesArray[i] as SourceEntry;
|
|
416
|
+
const sourceNode = sourceEntry.$_node as ReactiveNode;
|
|
417
|
+
// Access source to trigger its recomputation if needed
|
|
418
|
+
try {
|
|
419
|
+
computedRead(sourceNode);
|
|
420
|
+
} catch {
|
|
421
|
+
// Error counts as changed - caller will recompute and may handle differently
|
|
422
|
+
tracked = prevTracked;
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
// Check if source version changed (meaning its value changed)
|
|
426
|
+
// Early exit - runWithTracking will update all versions during recomputation
|
|
427
|
+
if (sourceEntry.$_version !== sourceNode.$_version) {
|
|
428
|
+
tracked = prevTracked;
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
tracked = prevTracked;
|
|
433
|
+
return false;
|
|
434
|
+
};
|