@slimlib/store 1.6.2 → 2.0.1
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/dist/index.mjs
CHANGED
|
@@ -1 +1,1067 @@
|
|
|
1
|
-
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Active scope for effect tracking
|
|
5
|
+
* When set, effects created will be tracked to this scope
|
|
6
|
+
* Can be set via setActiveScope() or automatically during scope() callbacks
|
|
7
|
+
*/
|
|
8
|
+
let activeScope;
|
|
9
|
+
/**
|
|
10
|
+
* Set the active scope for effect tracking
|
|
11
|
+
* Effects created outside of a scope() callback will be tracked to this scope
|
|
12
|
+
* Pass undefined to clear the active scope
|
|
13
|
+
*/
|
|
14
|
+
const setActiveScope = (scope) => {
|
|
15
|
+
activeScope = scope;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Scheduler function used to schedule effect execution
|
|
19
|
+
* Defaults to queueMicrotask, can be replaced with setScheduler
|
|
20
|
+
*/
|
|
21
|
+
let scheduler = queueMicrotask;
|
|
22
|
+
/**
|
|
23
|
+
* Set a custom scheduler function for effect execution
|
|
24
|
+
*/
|
|
25
|
+
const setScheduler = (newScheduler) => {
|
|
26
|
+
scheduler = newScheduler;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Symbols for objects that ARE exposed to users
|
|
30
|
+
// These must remain symbols to avoid leaking internal implementation details
|
|
31
|
+
const [unwrap, propertyDepsSymbol, trackSymbol, childrenSymbol,
|
|
32
|
+
// biome-ignore lint/suspicious/noSparseArray: fine
|
|
33
|
+
] = Array.from([, , , ,], Symbol);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Core reactive system implementation using push/pull algorithm:
|
|
37
|
+
*
|
|
38
|
+
* PUSH PHASE: When a source value changes (signal/state write):
|
|
39
|
+
* - Increment globalVersion
|
|
40
|
+
* - Eagerly propagate CHECK flags to all dependents
|
|
41
|
+
* - Schedule effects for execution
|
|
42
|
+
*
|
|
43
|
+
* PULL PHASE: When a computed/effect is read:
|
|
44
|
+
* - Check if recomputation is needed (via flags and source versions)
|
|
45
|
+
* - Lazily recompute by pulling values from sources
|
|
46
|
+
* - Update cached value and version
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* No-op getter used as default for DepsSet.$_getter to ensure all DepsSet
|
|
50
|
+
* instances have the same field representation (function, never undefined).
|
|
51
|
+
* This avoids V8 field representation deopts when some DepsSets have a getter
|
|
52
|
+
* and others don't.
|
|
53
|
+
*/
|
|
54
|
+
/* v8 ignore next -- @preserve */
|
|
55
|
+
const noopGetter = () => undefined;
|
|
56
|
+
/**
|
|
57
|
+
* DepsSet extends Set with $_version and $_getter properties for dependency
|
|
58
|
+
* tracking. Using a class ensures all instances share the same V8 hidden class,
|
|
59
|
+
* avoiding hidden class transitions and field constness changes.
|
|
60
|
+
*
|
|
61
|
+
* IMPORTANT: $_getter always defaults to a no-op function (never undefined)
|
|
62
|
+
* so that all DepsSet instances share the same V8 field representation.
|
|
63
|
+
* This prevents "dependent field representation changed" deopts.
|
|
64
|
+
*/
|
|
65
|
+
class DepsSet extends Set {
|
|
66
|
+
a = 0;
|
|
67
|
+
b;
|
|
68
|
+
constructor(getter) {
|
|
69
|
+
super();
|
|
70
|
+
this.b = getter;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Factory for creating source entries (unified allocation site).
|
|
75
|
+
*
|
|
76
|
+
* Both state and computed source entries MUST be created through this single
|
|
77
|
+
* factory to ensure they share the same V8 hidden class. Using separate
|
|
78
|
+
* object literals in different functions creates different allocation sites,
|
|
79
|
+
* causing V8 to assign different hidden classes and making all property
|
|
80
|
+
* accesses on source entries polymorphic.
|
|
81
|
+
*
|
|
82
|
+
* @param dependents - The DepsSet tracking dependents of this source
|
|
83
|
+
* @param node - The source ReactiveNode (undefined for state/signal sources)
|
|
84
|
+
* @param version - Initial version number
|
|
85
|
+
* @param getter - Value getter (undefined for computed sources)
|
|
86
|
+
* @param storedValue - Cached value (undefined for computed sources)
|
|
87
|
+
*/
|
|
88
|
+
const createSourceEntry = (dependents, node, version, getter, storedValue) => ({
|
|
89
|
+
c: dependents,
|
|
90
|
+
d: node,
|
|
91
|
+
a: version,
|
|
92
|
+
b: getter,
|
|
93
|
+
e: storedValue,
|
|
94
|
+
});
|
|
95
|
+
let flushScheduled = false;
|
|
96
|
+
/**
|
|
97
|
+
* Global version counter - increments on every signal/state write
|
|
98
|
+
* Used for fast-path: if globalVersion hasn't changed since last read, skip all checks
|
|
99
|
+
*/
|
|
100
|
+
let globalVersion = 1;
|
|
101
|
+
const batched = [];
|
|
102
|
+
let lastAddedId = 0;
|
|
103
|
+
let needsSort = false;
|
|
104
|
+
// Computation tracking state
|
|
105
|
+
let currentComputing;
|
|
106
|
+
let tracked = true;
|
|
107
|
+
/**
|
|
108
|
+
* Set the tracked state for dependency tracking (internal use only)
|
|
109
|
+
*/
|
|
110
|
+
const setTracked = (value) => {
|
|
111
|
+
tracked = value;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Add an effect to the batched set
|
|
115
|
+
* PUSH PHASE: Part of effect scheduling during dirty propagation
|
|
116
|
+
* Caller must check Flag.NEEDS_WORK before calling to avoid duplicates
|
|
117
|
+
*/
|
|
118
|
+
const batchedAdd = (node) => {
|
|
119
|
+
const nodeId = node.f;
|
|
120
|
+
// Track if we're adding out of order
|
|
121
|
+
if (nodeId < lastAddedId) {
|
|
122
|
+
needsSort = true;
|
|
123
|
+
}
|
|
124
|
+
lastAddedId = nodeId;
|
|
125
|
+
batched.push(node);
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Add a newly created effect to the batched set
|
|
129
|
+
* Used during effect creation - new effects always have the highest ID
|
|
130
|
+
* so we unconditionally update lastAddedId without checking order
|
|
131
|
+
*/
|
|
132
|
+
const batchedAddNew = (node, effectId) => {
|
|
133
|
+
lastAddedId = effectId;
|
|
134
|
+
batched.push(node);
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Unwraps a proxied value to get the underlying object
|
|
138
|
+
* (Utility - not specific to push/pull phases)
|
|
139
|
+
*/
|
|
140
|
+
const unwrapValue = (value) => (value !== null &&
|
|
141
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
142
|
+
value[unwrap]) ||
|
|
143
|
+
value;
|
|
144
|
+
/**
|
|
145
|
+
* Make a computed live - register it with all its sources
|
|
146
|
+
* Called when a live consumer starts reading this computed
|
|
147
|
+
* PUSH PHASE: Enables push notifications to flow through this node
|
|
148
|
+
*/
|
|
149
|
+
const makeLive = (node) => {
|
|
150
|
+
node.g |= 64 /* Flag.LIVE */;
|
|
151
|
+
const nodeSources = node.h;
|
|
152
|
+
for (let i = 0, len = nodeSources.length; i < len; ++i) {
|
|
153
|
+
const { c: c, d: sourceNode } = nodeSources[i];
|
|
154
|
+
c.add(node);
|
|
155
|
+
if (sourceNode !== undefined && (sourceNode.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) === 0) {
|
|
156
|
+
makeLive(sourceNode);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Make a computed non-live - unregister it from all its sources
|
|
162
|
+
* Called when all live consumers stop depending on this computed
|
|
163
|
+
* PUSH PHASE: Disables push notifications through this node
|
|
164
|
+
*/
|
|
165
|
+
const makeNonLive = (node) => {
|
|
166
|
+
node.g &= -65 /* Flag.LIVE */;
|
|
167
|
+
const nodeSources = node.h;
|
|
168
|
+
for (let i = 0, len = nodeSources.length; i < len; ++i) {
|
|
169
|
+
const { c: c, d: sourceNode } = nodeSources[i];
|
|
170
|
+
c.delete(node);
|
|
171
|
+
// Check: has Flag.LIVE but not Flag.EFFECT (effects never become non-live)
|
|
172
|
+
if (sourceNode !== undefined &&
|
|
173
|
+
(sourceNode.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) === 64 /* Flag.LIVE */ &&
|
|
174
|
+
sourceNode.i.size === 0) {
|
|
175
|
+
makeNonLive(sourceNode);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Clear sources for a node starting from a specific index
|
|
181
|
+
* PULL PHASE: Cleanup during dependency tracking when sources change
|
|
182
|
+
*/
|
|
183
|
+
const clearSources = (node, fromIndex = 0) => {
|
|
184
|
+
const isLive = (node.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) !== 0;
|
|
185
|
+
const nodeSources = node.h;
|
|
186
|
+
for (let i = fromIndex, len = nodeSources.length; i < len; ++i) {
|
|
187
|
+
const { c: c, d: sourceNode } = nodeSources[i];
|
|
188
|
+
// Check if this deps is retained (exists in kept portion) - avoid removing shared deps
|
|
189
|
+
let retained = false;
|
|
190
|
+
for (let j = 0; j < fromIndex && !retained; ++j) {
|
|
191
|
+
retained = nodeSources[j].c === c;
|
|
192
|
+
}
|
|
193
|
+
if (!retained) {
|
|
194
|
+
// Always remove from deps to prevent stale notifications
|
|
195
|
+
c.delete(node);
|
|
196
|
+
// If source is a computed and we're live, check if it became non-live
|
|
197
|
+
if (isLive &&
|
|
198
|
+
sourceNode !== undefined &&
|
|
199
|
+
(sourceNode.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) === 64 /* Flag.LIVE */ &&
|
|
200
|
+
sourceNode.i.size === 0) {
|
|
201
|
+
makeNonLive(sourceNode);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
nodeSources.length = fromIndex;
|
|
206
|
+
};
|
|
207
|
+
/**
|
|
208
|
+
* Execute all pending effects immediately
|
|
209
|
+
* This function can be called to manually trigger all scheduled effects
|
|
210
|
+
* before the next microtask
|
|
211
|
+
* PULL PHASE: Executes batched effects, each effect pulls its dependencies
|
|
212
|
+
*/
|
|
213
|
+
const flushEffects = () => {
|
|
214
|
+
flushScheduled = false;
|
|
215
|
+
// Collect nodes, only sort if effects were added out of order
|
|
216
|
+
// Use a copy of the array for execution to allow re-scheduling
|
|
217
|
+
const nodes = batched.slice();
|
|
218
|
+
batched.length = 0;
|
|
219
|
+
if (needsSort) {
|
|
220
|
+
nodes.sort((a, b) => a.f - b.f);
|
|
221
|
+
}
|
|
222
|
+
lastAddedId = 0;
|
|
223
|
+
needsSort = false;
|
|
224
|
+
// Call $_fn on each node instead of calling nodes directly as functions
|
|
225
|
+
// This enables effect nodes to be plain objects (same hidden class as computed)
|
|
226
|
+
for (let i = 0, len = nodes.length; i < len; ++i) {
|
|
227
|
+
const node = nodes[i];
|
|
228
|
+
try {
|
|
229
|
+
node.j?.();
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
console.error(e);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Schedule flush via scheduler (default: microtask)
|
|
238
|
+
* PUSH PHASE: Schedules the transition from push to pull phase
|
|
239
|
+
*/
|
|
240
|
+
const scheduleFlush = () => {
|
|
241
|
+
if (!flushScheduled) {
|
|
242
|
+
flushScheduled = true;
|
|
243
|
+
scheduler(flushEffects);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Track a state/signal dependency between currentComputing and a deps Set
|
|
248
|
+
* Uses liveness tracking - only live consumers register with sources
|
|
249
|
+
* PULL PHASE: Records dependencies during computation for future invalidation
|
|
250
|
+
*/
|
|
251
|
+
const trackStateDependency = (deps, valueGetter, cachedValue) => {
|
|
252
|
+
// Callers guarantee tracked && currentComputing are true
|
|
253
|
+
const sourcesArray = currentComputing.h;
|
|
254
|
+
const skipIndex = currentComputing.k;
|
|
255
|
+
const existing = sourcesArray[skipIndex];
|
|
256
|
+
const noSource = existing === undefined;
|
|
257
|
+
if (noSource || existing.c !== deps) {
|
|
258
|
+
// Different dependency - clear old ones from this point and rebuild
|
|
259
|
+
if (!noSource) {
|
|
260
|
+
clearSources(currentComputing, skipIndex);
|
|
261
|
+
}
|
|
262
|
+
// Track deps version, value getter, and last seen value for polling
|
|
263
|
+
// Uses shared createSourceEntry factory for V8 hidden class monomorphism
|
|
264
|
+
sourcesArray.push(createSourceEntry(deps, undefined, deps.a, valueGetter, cachedValue));
|
|
265
|
+
// Mark that this node has state/signal sources (for polling optimization)
|
|
266
|
+
currentComputing.g |= 128 /* Flag.HAS_STATE_SOURCE */;
|
|
267
|
+
// Only register with source if we're live
|
|
268
|
+
if ((currentComputing.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) !== 0) {
|
|
269
|
+
deps.add(currentComputing);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
// Same state source - update depsVersion, getter, and storedValue for accurate polling
|
|
274
|
+
const entry = sourcesArray[skipIndex];
|
|
275
|
+
entry.a = deps.a;
|
|
276
|
+
entry.b = valueGetter;
|
|
277
|
+
entry.e = cachedValue;
|
|
278
|
+
// Re-set Flag.HAS_STATE_SOURCE (may have been cleared by runWithTracking)
|
|
279
|
+
currentComputing.g |= 128 /* Flag.HAS_STATE_SOURCE */;
|
|
280
|
+
}
|
|
281
|
+
++currentComputing.k;
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Mark a node as needing check (eager propagation with equality cutoff)
|
|
285
|
+
* PUSH PHASE: Eagerly propagates CHECK flag up the dependency graph
|
|
286
|
+
*/
|
|
287
|
+
const markNeedsCheck = (node) => {
|
|
288
|
+
const flags = node.g;
|
|
289
|
+
// Fast path: skip if already computing, dirty, or marked CHECK
|
|
290
|
+
// (COMPUTING | DIRTY | CHECK) (bits 0, 1, 2)
|
|
291
|
+
if ((flags & (4 /* Flag.COMPUTING */ | 1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */)) !== 0) {
|
|
292
|
+
// Exception: computing effect that's not dirty yet needs to be marked dirty
|
|
293
|
+
// Uses combined mask: (flags & 13) === 12 means COMPUTING + EFFECT set, DIRTY not set
|
|
294
|
+
if ((flags & (4 /* Flag.COMPUTING */ | 8 /* Flag.EFFECT */ | 1 /* Flag.DIRTY */)) === (4 /* Flag.COMPUTING */ | 8 /* Flag.EFFECT */)) {
|
|
295
|
+
node.g = flags | 1 /* Flag.DIRTY */;
|
|
296
|
+
batchedAdd(node);
|
|
297
|
+
scheduleFlush();
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Not skipped: set CHECK and propagate to dependents
|
|
302
|
+
node.g = flags | 2 /* Flag.CHECK */;
|
|
303
|
+
if ((flags & 8 /* Flag.EFFECT */) !== 0) {
|
|
304
|
+
batchedAdd(node);
|
|
305
|
+
scheduleFlush();
|
|
306
|
+
}
|
|
307
|
+
for (const dep of node.i) {
|
|
308
|
+
markNeedsCheck(dep);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
/**
|
|
312
|
+
* Mark all dependents in a Set as needing check
|
|
313
|
+
* PUSH PHASE: Entry point for push propagation when a source value changes
|
|
314
|
+
*/
|
|
315
|
+
const markDependents = (deps) => {
|
|
316
|
+
++globalVersion;
|
|
317
|
+
// Increment deps version for non-live computed polling
|
|
318
|
+
++deps.a;
|
|
319
|
+
for (const dep of deps) {
|
|
320
|
+
markNeedsCheck(dep);
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Run a getter function with dependency tracking
|
|
325
|
+
* Handles cycle detection, context setup, and source cleanup
|
|
326
|
+
* PULL PHASE: Core of pull - executes computation while tracking dependencies
|
|
327
|
+
*/
|
|
328
|
+
const runWithTracking = (node, getter) => {
|
|
329
|
+
// Clear (DIRTY | CHECK) and source flags (will be recalculated during tracking)
|
|
330
|
+
// Note: Even when called from checkComputedSources (which sets tracked=false), this works
|
|
331
|
+
// because runWithTracking sets tracked=true, so trackDependency will re-set the flag
|
|
332
|
+
node.g = (node.g & -388) | 4 /* Flag.COMPUTING */;
|
|
333
|
+
node.k = 0;
|
|
334
|
+
const prev = currentComputing;
|
|
335
|
+
const prevTracked = tracked;
|
|
336
|
+
currentComputing = node;
|
|
337
|
+
tracked = true;
|
|
338
|
+
try {
|
|
339
|
+
return getter();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
currentComputing = prev;
|
|
343
|
+
tracked = prevTracked;
|
|
344
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
345
|
+
const flags = (node.g &= -5 /* Flag.COMPUTING */);
|
|
346
|
+
const nodeSources = node.h;
|
|
347
|
+
const skipped = node.k;
|
|
348
|
+
const nodeSourcesLength = nodeSources.length;
|
|
349
|
+
// Only update versions if there are computed sources (state sources update inline)
|
|
350
|
+
if ((flags & 256 /* Flag.HAS_COMPUTED_SOURCE */) !== 0) {
|
|
351
|
+
const updateLen = Math.min(skipped, nodeSourcesLength);
|
|
352
|
+
for (let i = 0; i < updateLen; ++i) {
|
|
353
|
+
const entry = nodeSources[i];
|
|
354
|
+
const entryNode = entry.d;
|
|
355
|
+
if (entryNode !== undefined) {
|
|
356
|
+
entry.a = entryNode.a;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Clean up any excess sources that weren't reused
|
|
361
|
+
if (nodeSourcesLength > skipped) {
|
|
362
|
+
clearSources(node, skipped);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* Execute a callback without tracking any reactive dependencies
|
|
368
|
+
* Useful when reading signals/state without creating a dependency relationship
|
|
369
|
+
* PULL PHASE: Temporarily disables dependency tracking during pull
|
|
370
|
+
*/
|
|
371
|
+
const untracked = (callback) => {
|
|
372
|
+
const prevTracked = tracked;
|
|
373
|
+
tracked = false;
|
|
374
|
+
try {
|
|
375
|
+
return callback();
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
tracked = prevTracked;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
/**
|
|
382
|
+
* Check if any computed sources have changed or errored.
|
|
383
|
+
* Used by CHECK path optimization in computed and effect.
|
|
384
|
+
* PULL PHASE: Verifies if sources actually changed before recomputing (equality cutoff)
|
|
385
|
+
*
|
|
386
|
+
* Note: Callers must check HAS_STATE_SOURCE flag before calling this function.
|
|
387
|
+
* This function assumes all sources are computed (have $_node).
|
|
388
|
+
*
|
|
389
|
+
* @param sourcesArray - The sources to check (must all be computed sources)
|
|
390
|
+
* @returns true if sources changed, false if unchanged
|
|
391
|
+
*/
|
|
392
|
+
const checkComputedSources = (sourcesArray) => {
|
|
393
|
+
const prevTracked = tracked;
|
|
394
|
+
tracked = false;
|
|
395
|
+
const len = sourcesArray.length;
|
|
396
|
+
for (let i = 0; i < len; ++i) {
|
|
397
|
+
const sourceEntry = sourcesArray[i];
|
|
398
|
+
const sourceNode = sourceEntry.d;
|
|
399
|
+
// Access source to trigger its recomputation if needed
|
|
400
|
+
try {
|
|
401
|
+
computedRead(sourceNode);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Error counts as changed - caller will recompute and may handle differently
|
|
405
|
+
tracked = prevTracked;
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
// Check if source version changed (meaning its value changed)
|
|
409
|
+
// Early exit - runWithTracking will update all versions during recomputation
|
|
410
|
+
if (sourceEntry.a !== sourceNode.a) {
|
|
411
|
+
tracked = prevTracked;
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
tracked = prevTracked;
|
|
416
|
+
return false;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Debug configuration flag: Warn when writing to signals/state inside a computed
|
|
421
|
+
*/
|
|
422
|
+
const WARN_ON_WRITE_IN_COMPUTED = 1 << 0;
|
|
423
|
+
/**
|
|
424
|
+
* Debug configuration flag: Suppress warning when effects are disposed by GC instead of explicitly
|
|
425
|
+
*/
|
|
426
|
+
const SUPPRESS_EFFECT_GC_WARNING = 1 << 1;
|
|
427
|
+
/**
|
|
428
|
+
* Debug configuration flag: Warn when effects are created without an active scope
|
|
429
|
+
* This is an allowed pattern, but teams may choose to enforce scope usage for better effect lifecycle management
|
|
430
|
+
*/
|
|
431
|
+
const WARN_ON_UNTRACKED_EFFECT = 1 << 2;
|
|
432
|
+
/**
|
|
433
|
+
* Current debug configuration bitfield
|
|
434
|
+
*/
|
|
435
|
+
let debugConfigFlags = 0;
|
|
436
|
+
/**
|
|
437
|
+
* Configure debug behavior using a bitfield of flags
|
|
438
|
+
*/
|
|
439
|
+
const debugConfig = (flags) => {
|
|
440
|
+
debugConfigFlags = flags | 0;
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Safely call each function in an iterable, logging any errors to console
|
|
444
|
+
*/
|
|
445
|
+
const safeForEach = (fns) => {
|
|
446
|
+
for (let i = 0, len = fns.length; i < len; ++i) {
|
|
447
|
+
const fn = fns[i];
|
|
448
|
+
try {
|
|
449
|
+
fn?.();
|
|
450
|
+
}
|
|
451
|
+
catch (e) {
|
|
452
|
+
console.error(e);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
/**
|
|
457
|
+
* Warn if writing inside a computed (not an effect)
|
|
458
|
+
* Only runs in DEV mode and when configured
|
|
459
|
+
*/
|
|
460
|
+
const warnIfWriteInComputed = (context) => {
|
|
461
|
+
if (DEV && (debugConfigFlags & WARN_ON_WRITE_IN_COMPUTED) !== 0 && currentComputing && (currentComputing.g & 8 /* Flag.EFFECT */) === 0) {
|
|
462
|
+
console.warn(`[@slimlib/store] Writing to ${context} inside a computed is not recommended. The computed will not automatically re-run when this value changes, which may lead to stale values.`);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
/**
|
|
466
|
+
* FinalizationRegistry for detecting effects that are GC'd without being properly disposed.
|
|
467
|
+
* Only created in DEV mode.
|
|
468
|
+
*/
|
|
469
|
+
const effectRegistry = DEV
|
|
470
|
+
? new FinalizationRegistry((stackTrace) => {
|
|
471
|
+
if ((debugConfigFlags & SUPPRESS_EFFECT_GC_WARNING) === 0) {
|
|
472
|
+
console.warn(`[@slimlib/store] Effect was garbage collected without being disposed. This may indicate a memory leak. Effects should be disposed by calling the returned dispose function or by using a scope that is properly disposed.\n\nEffect was created at:\n${stackTrace}`);
|
|
473
|
+
}
|
|
474
|
+
})
|
|
475
|
+
: null;
|
|
476
|
+
/**
|
|
477
|
+
* Register an effect for GC tracking.
|
|
478
|
+
* Returns a token that must be passed to unregisterEffect when the effect is properly disposed.
|
|
479
|
+
* Only active in DEV mode; returns undefined in production.
|
|
480
|
+
*/
|
|
481
|
+
const registerEffect = DEV
|
|
482
|
+
? () => {
|
|
483
|
+
const token = {};
|
|
484
|
+
// Capture stack trace at effect creation for better debugging
|
|
485
|
+
// Remove the first few lines (Error + registerEffect call) to get to the actual effect() call
|
|
486
|
+
const relevantStack = String(new Error().stack).split('\n').slice(3).join('\n');
|
|
487
|
+
effectRegistry.register(token, relevantStack, token);
|
|
488
|
+
return token;
|
|
489
|
+
}
|
|
490
|
+
: () => undefined;
|
|
491
|
+
/**
|
|
492
|
+
* Unregister an effect from GC tracking (called when effect is properly disposed).
|
|
493
|
+
* Only active in DEV mode.
|
|
494
|
+
*/
|
|
495
|
+
const unregisterEffect = DEV
|
|
496
|
+
? (token) => {
|
|
497
|
+
effectRegistry?.unregister(token);
|
|
498
|
+
}
|
|
499
|
+
: () => { };
|
|
500
|
+
/**
|
|
501
|
+
* Warn if an effect is created without an active scope.
|
|
502
|
+
* Only runs in DEV mode and when WARN_ON_UNTRACKED_EFFECT is enabled.
|
|
503
|
+
*/
|
|
504
|
+
const warnIfNoActiveScope = DEV
|
|
505
|
+
? (activeScope) => {
|
|
506
|
+
if ((debugConfigFlags & WARN_ON_UNTRACKED_EFFECT) !== 0 && !activeScope) {
|
|
507
|
+
console.warn(`[@slimlib/store] Effect created without an active scope. Consider using scope() or setActiveScope() to track effects for proper lifecycle management.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
: () => { };
|
|
511
|
+
const cycleMessage = 'Detected cycle in computations.';
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read function for computed nodes
|
|
515
|
+
*/
|
|
516
|
+
function computedRead(self) {
|
|
517
|
+
// ===== PULL PHASE: Register this computed as a dependency of the current consumer =====
|
|
518
|
+
// Track if someone is reading us
|
|
519
|
+
if (tracked && currentComputing !== undefined) {
|
|
520
|
+
// Inline tracking for computed dependencies
|
|
521
|
+
const consumerSources = currentComputing.h;
|
|
522
|
+
const skipIndex = currentComputing.k;
|
|
523
|
+
const deps = self.i;
|
|
524
|
+
const existing = consumerSources[skipIndex];
|
|
525
|
+
const noSource = existing === undefined;
|
|
526
|
+
if (noSource || existing.c !== deps) {
|
|
527
|
+
// Different dependency - clear old ones from this point and rebuild
|
|
528
|
+
if (!noSource) {
|
|
529
|
+
clearSources(currentComputing, skipIndex);
|
|
530
|
+
}
|
|
531
|
+
// Push source entry - version will be updated after source computes
|
|
532
|
+
// Uses shared createSourceEntry factory for V8 hidden class monomorphism
|
|
533
|
+
consumerSources.push(createSourceEntry(deps, self, 0, undefined, undefined));
|
|
534
|
+
// Only register with source if we're live
|
|
535
|
+
if ((currentComputing.g & (8 /* Flag.EFFECT */ | 64 /* Flag.LIVE */)) !== 0) {
|
|
536
|
+
deps.add(currentComputing);
|
|
537
|
+
// If source computed is not live, make it live
|
|
538
|
+
if ((self.g & 64 /* Flag.LIVE */) === 0) {
|
|
539
|
+
makeLive(self);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// Mark that this node has computed sources (for version update loop optimization)
|
|
544
|
+
currentComputing.g |= 256 /* Flag.HAS_COMPUTED_SOURCE */;
|
|
545
|
+
++currentComputing.k;
|
|
546
|
+
}
|
|
547
|
+
let flags = self.g;
|
|
548
|
+
const sourcesArray = self.h;
|
|
549
|
+
// Cycle detection: if this computed is already being computed, we have a cycle
|
|
550
|
+
// This matches TC39 Signals proposal behavior: throw an error on cyclic reads
|
|
551
|
+
if ((flags & 4 /* Flag.COMPUTING */) !== 0) {
|
|
552
|
+
throw new Error(cycleMessage);
|
|
553
|
+
}
|
|
554
|
+
// ===== PULL PHASE: Check if cached value can be returned =====
|
|
555
|
+
// Combined check: node has cached result and doesn't need work
|
|
556
|
+
const hasCached = (flags & (16 /* Flag.HAS_VALUE */ | 32 /* Flag.HAS_ERROR */)) !== 0;
|
|
557
|
+
// biome-ignore lint/suspicious/noConfusingLabels: expected
|
|
558
|
+
checkCache: if ((flags & (1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */)) === 0 && hasCached) {
|
|
559
|
+
// Fast-path: nothing has changed globally since last read
|
|
560
|
+
if (self.f === globalVersion) {
|
|
561
|
+
if ((flags & 32 /* Flag.HAS_ERROR */) !== 0) {
|
|
562
|
+
throw self.l;
|
|
563
|
+
}
|
|
564
|
+
return self.l;
|
|
565
|
+
}
|
|
566
|
+
// ===== PULL PHASE: Poll sources for non-live computeds =====
|
|
567
|
+
// Non-live nodes poll instead of receiving push notifications
|
|
568
|
+
if ((flags & 64 /* Flag.LIVE */) === 0) {
|
|
569
|
+
let sourceChanged = false;
|
|
570
|
+
// Disable tracking while polling sources to avoid unnecessary dependency tracking
|
|
571
|
+
const prevTracked = tracked;
|
|
572
|
+
// TODO: inline after it combined to a single scope?
|
|
573
|
+
setTracked(false);
|
|
574
|
+
for (let i = 0, len = sourcesArray.length; i < len; ++i) {
|
|
575
|
+
const source = sourcesArray[i];
|
|
576
|
+
const sourceNode = source.d; // Extract once at loop start
|
|
577
|
+
if (sourceNode === undefined) {
|
|
578
|
+
// State source - check if deps version changed
|
|
579
|
+
const currentDepsVersion = source.c.a;
|
|
580
|
+
if (source.a !== currentDepsVersion) {
|
|
581
|
+
// Deps version changed, check if actual value reverted (primitives only)
|
|
582
|
+
const storedValue = source.e;
|
|
583
|
+
const storedType = typeof storedValue;
|
|
584
|
+
if (storedValue === null || (storedType !== 'object' && storedType !== 'function')) {
|
|
585
|
+
const currentValue = source.b();
|
|
586
|
+
if (Object.is(currentValue, storedValue)) {
|
|
587
|
+
// Value reverted - update depsVersion and continue checking
|
|
588
|
+
source.a = currentDepsVersion;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// Value actually changed - mark DIRTY and skip remaining
|
|
593
|
+
sourceChanged = true;
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
// Computed source - use sourceNode directly (already extracted above)
|
|
599
|
+
try {
|
|
600
|
+
computedRead(sourceNode);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// Error counts as changed
|
|
604
|
+
sourceChanged = true;
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
if (source.a !== sourceNode.a) {
|
|
608
|
+
sourceChanged = true;
|
|
609
|
+
break; // EXIT EARLY - don't process remaining sources
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
setTracked(prevTracked);
|
|
614
|
+
if (sourceChanged) {
|
|
615
|
+
// Source changed or threw - mark DIRTY and proceed to recompute
|
|
616
|
+
self.g = flags |= 1 /* Flag.DIRTY */;
|
|
617
|
+
break checkCache;
|
|
618
|
+
}
|
|
619
|
+
// No sources changed - return cached value
|
|
620
|
+
self.f = globalVersion;
|
|
621
|
+
if ((flags & 32 /* Flag.HAS_ERROR */) !== 0) {
|
|
622
|
+
throw self.l;
|
|
623
|
+
}
|
|
624
|
+
return self.l;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// ===== PULL PHASE: Verify CHECK state for live computeds =====
|
|
628
|
+
// Live computeds receive CHECK via push - verify sources before recomputing
|
|
629
|
+
// Non-live computeds already verified above during polling
|
|
630
|
+
// Note: Check for Flag.HAS_VALUE OR Flag.HAS_ERROR since cached errors should also use this path
|
|
631
|
+
if ((flags & (1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */ | 128 /* Flag.HAS_STATE_SOURCE */)) === 2 /* Flag.CHECK */ && hasCached) {
|
|
632
|
+
if (checkComputedSources(sourcesArray)) {
|
|
633
|
+
// Sources changed or errored - mark DIRTY and let getter run
|
|
634
|
+
flags = (flags & -3 /* Flag.CHECK */) | 1 /* Flag.DIRTY */;
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
// Sources unchanged, clear CHECK flag and return cached value
|
|
638
|
+
self.g = flags & -3 /* Flag.CHECK */;
|
|
639
|
+
// No sources changed - return cached value
|
|
640
|
+
self.f = globalVersion;
|
|
641
|
+
if ((flags & 32 /* Flag.HAS_ERROR */) !== 0) {
|
|
642
|
+
throw self.l;
|
|
643
|
+
}
|
|
644
|
+
return self.l;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// ===== PULL PHASE: Recompute value by pulling from sources =====
|
|
648
|
+
// Recompute if dirty or check (sources actually changed)
|
|
649
|
+
if ((flags & (1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */)) !== 0) {
|
|
650
|
+
const wasDirty = (flags & 1 /* Flag.DIRTY */) !== 0;
|
|
651
|
+
runWithTracking(self, () => {
|
|
652
|
+
try {
|
|
653
|
+
const newValue = self.j();
|
|
654
|
+
// Check if value actually changed (common path: no error recovery)
|
|
655
|
+
const changed = (flags & 16 /* Flag.HAS_VALUE */) === 0 || !self.m(self.l, newValue);
|
|
656
|
+
if (changed) {
|
|
657
|
+
self.l = newValue;
|
|
658
|
+
// Increment version to indicate value changed (for polling)
|
|
659
|
+
++self.a;
|
|
660
|
+
self.g = (self.g | 16 /* Flag.HAS_VALUE */) & ~32 /* Flag.HAS_ERROR */;
|
|
661
|
+
// ===== PUSH PHASE (during pull): Mark CHECK-only dependents as DIRTY =====
|
|
662
|
+
// When value changes during recomputation, upgrade dependent CHECK flags to DIRTY
|
|
663
|
+
for (const dep of self.i) {
|
|
664
|
+
const depFlags = dep.g;
|
|
665
|
+
if ((depFlags & (4 /* Flag.COMPUTING */ | 1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */)) === 2 /* Flag.CHECK */) {
|
|
666
|
+
dep.g = depFlags | 1 /* Flag.DIRTY */;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
else if (wasDirty) {
|
|
671
|
+
self.g |= 16 /* Flag.HAS_VALUE */;
|
|
672
|
+
}
|
|
673
|
+
// Update last seen global version
|
|
674
|
+
self.f = globalVersion;
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
// Per TC39 Signals proposal: cache the error and mark as clean with error flag
|
|
678
|
+
// The error will be rethrown on subsequent reads until a dependency changes
|
|
679
|
+
// Reuse valueSymbol for error storage since a computed can't have both value and error
|
|
680
|
+
// Increment version since the result changed (to error)
|
|
681
|
+
++self.a;
|
|
682
|
+
self.l = e;
|
|
683
|
+
self.g = (self.g & ~16 /* Flag.HAS_VALUE */) | 32 /* Flag.HAS_ERROR */;
|
|
684
|
+
self.f = globalVersion;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
// Check if we have a cached error to rethrow (stored in valueSymbol)
|
|
689
|
+
if ((self.g & 32 /* Flag.HAS_ERROR */) !== 0) {
|
|
690
|
+
throw self.l;
|
|
691
|
+
}
|
|
692
|
+
return self.l;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Creates a computed value that automatically tracks dependencies and caches results
|
|
696
|
+
*/
|
|
697
|
+
const computed = (getter, equals = Object.is) => {
|
|
698
|
+
const node = {
|
|
699
|
+
h: [],
|
|
700
|
+
i: new DepsSet(noopGetter),
|
|
701
|
+
g: 1 /* Flag.DIRTY */,
|
|
702
|
+
k: 0,
|
|
703
|
+
a: 0,
|
|
704
|
+
l: undefined,
|
|
705
|
+
f: 0,
|
|
706
|
+
j: getter,
|
|
707
|
+
m: equals,
|
|
708
|
+
};
|
|
709
|
+
return () => computedRead(node);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Effect creation counter - increments on every effect creation
|
|
714
|
+
* Used to maintain effect execution order by creation time
|
|
715
|
+
*/
|
|
716
|
+
let effectCreationCounter = 0;
|
|
717
|
+
/**
|
|
718
|
+
* Creates a reactive effect that runs when dependencies change
|
|
719
|
+
*/
|
|
720
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: void is semantically correct here - callback may return nothing or a cleanup function
|
|
721
|
+
const effect = (callback) => {
|
|
722
|
+
let disposed = false;
|
|
723
|
+
// Register effect for GC tracking (only in DEV mode)
|
|
724
|
+
const gcToken = registerEffect();
|
|
725
|
+
// Warn if effect is created without an active scope (only in DEV mode when enabled)
|
|
726
|
+
warnIfNoActiveScope(activeScope);
|
|
727
|
+
// Declare node first so the runner closure can capture it.
|
|
728
|
+
// The variable will be assigned before the runner is ever called.
|
|
729
|
+
let node;
|
|
730
|
+
// Define the runner function BEFORE creating the node so that $_fn
|
|
731
|
+
// is a function from the start (Fix #1: avoids hidden class transition
|
|
732
|
+
// from undefined → function on the $_fn field).
|
|
733
|
+
const runner = () => {
|
|
734
|
+
// Skip if effect was disposed (may still be in batched queue from before disposal)
|
|
735
|
+
if (disposed) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
// Cycle detection: if this node is already being computed, we have a cycle
|
|
739
|
+
const flags = node.g;
|
|
740
|
+
if ((flags & 4 /* Flag.COMPUTING */) !== 0) {
|
|
741
|
+
throw new Error(cycleMessage);
|
|
742
|
+
}
|
|
743
|
+
// ----------------------------------------------------------------
|
|
744
|
+
// PULL PHASE: Verify if sources actually changed before running
|
|
745
|
+
// ----------------------------------------------------------------
|
|
746
|
+
// Bail-out optimization: if only CHECK flag is set (not DIRTY),
|
|
747
|
+
// verify that computed sources actually changed before running
|
|
748
|
+
if ((flags & (1 /* Flag.DIRTY */ | 2 /* Flag.CHECK */ | 128 /* Flag.HAS_STATE_SOURCE */)) === 2 /* Flag.CHECK */) {
|
|
749
|
+
// PULL: Read computed sources to check if they changed
|
|
750
|
+
// If false, sources didn't change - clear CHECK flag and skip
|
|
751
|
+
// If true, sources changed or errored - proceed to run
|
|
752
|
+
if (!checkComputedSources(node.h)) {
|
|
753
|
+
node.g = flags & -3 /* Flag.CHECK */;
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// ----------------------------------------------------------------
|
|
758
|
+
// PULL PHASE: Execute effect and track dependencies
|
|
759
|
+
// ----------------------------------------------------------------
|
|
760
|
+
runWithTracking(node, () => {
|
|
761
|
+
// Run previous cleanup if it exists (stored in $_value)
|
|
762
|
+
if (typeof node.l === 'function') {
|
|
763
|
+
node.l();
|
|
764
|
+
}
|
|
765
|
+
// Run the callback and store new cleanup in $_value
|
|
766
|
+
// (callback will PULL values from signals/state/computed)
|
|
767
|
+
node.l = callback();
|
|
768
|
+
});
|
|
769
|
+
};
|
|
770
|
+
// Create effect node as a plain object with IDENTICAL initial field types
|
|
771
|
+
// as computed nodes to ensure V8 hidden class monomorphism (Fix #2):
|
|
772
|
+
// $_deps: new DepsSet() (Set object, same as computed — never used for effects)
|
|
773
|
+
// $_fn: runner (function, same as computed's getter)
|
|
774
|
+
// $_equals: Object.is (function, same as computed's equality comparator)
|
|
775
|
+
//
|
|
776
|
+
// $_value: stores cleanup function returned by the effect callback
|
|
777
|
+
// $_stamp: creation order counter for effect scheduling
|
|
778
|
+
node = {
|
|
779
|
+
h: [],
|
|
780
|
+
i: new DepsSet(noopGetter),
|
|
781
|
+
g: 1 /* Flag.DIRTY */ | 8 /* Flag.EFFECT */,
|
|
782
|
+
k: 0,
|
|
783
|
+
a: 0,
|
|
784
|
+
l: undefined,
|
|
785
|
+
f: ++effectCreationCounter,
|
|
786
|
+
j: runner,
|
|
787
|
+
m: Object.is,
|
|
788
|
+
};
|
|
789
|
+
const effectId = node.f;
|
|
790
|
+
const dispose = () => {
|
|
791
|
+
// Mark as disposed to prevent running if still in batched queue
|
|
792
|
+
disposed = true;
|
|
793
|
+
// Unregister from GC tracking (only in DEV mode)
|
|
794
|
+
unregisterEffect(gcToken);
|
|
795
|
+
// Run cleanup if it exists (stored in $_value)
|
|
796
|
+
if (typeof node.l === 'function') {
|
|
797
|
+
node.l();
|
|
798
|
+
}
|
|
799
|
+
clearSources(node);
|
|
800
|
+
};
|
|
801
|
+
// Track to appropriate scope
|
|
802
|
+
if (activeScope) {
|
|
803
|
+
activeScope[trackSymbol](dispose);
|
|
804
|
+
}
|
|
805
|
+
// ----------------------------------------------------------------
|
|
806
|
+
// Initial scheduling (triggers first PULL when flush runs)
|
|
807
|
+
// ----------------------------------------------------------------
|
|
808
|
+
// Trigger first run via batched queue
|
|
809
|
+
// node is already dirty
|
|
810
|
+
// and effect is for sure with the latest id so we directly adding without the sort
|
|
811
|
+
batchedAddNew(node, effectId);
|
|
812
|
+
scheduleFlush();
|
|
813
|
+
return dispose;
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Creates a reactive scope for tracking effects
|
|
818
|
+
* Effects created within a scope callback are automatically tracked and disposed together
|
|
819
|
+
*/
|
|
820
|
+
const scope = (callback, parent = activeScope) => {
|
|
821
|
+
const effects = [];
|
|
822
|
+
const children = [];
|
|
823
|
+
const cleanups = [];
|
|
824
|
+
let disposed = false;
|
|
825
|
+
let myIndex = -1;
|
|
826
|
+
/**
|
|
827
|
+
* Register a cleanup function to run when scope is disposed
|
|
828
|
+
*/
|
|
829
|
+
const onDispose = cleanup => {
|
|
830
|
+
if (disposed) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
cleanups.push(cleanup);
|
|
834
|
+
};
|
|
835
|
+
const ctx = ((cb) => {
|
|
836
|
+
if (!cb) {
|
|
837
|
+
// Dispose - return early if already disposed (idempotent)
|
|
838
|
+
if (disposed) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
// Dispose
|
|
842
|
+
disposed = true;
|
|
843
|
+
// Dispose children first (depth-first)
|
|
844
|
+
safeForEach(children);
|
|
845
|
+
// Stop all effects
|
|
846
|
+
safeForEach(effects);
|
|
847
|
+
effects.length = 0;
|
|
848
|
+
// Run cleanup handlers
|
|
849
|
+
safeForEach(cleanups);
|
|
850
|
+
// Remove from parent
|
|
851
|
+
if (parent) {
|
|
852
|
+
parent[childrenSymbol][myIndex] = undefined;
|
|
853
|
+
}
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// Extend scope - silently ignore if disposed
|
|
857
|
+
if (disposed) {
|
|
858
|
+
return ctx;
|
|
859
|
+
}
|
|
860
|
+
// Run callback in this scope's context
|
|
861
|
+
const prev = activeScope;
|
|
862
|
+
setActiveScope(ctx);
|
|
863
|
+
try {
|
|
864
|
+
cb(onDispose);
|
|
865
|
+
}
|
|
866
|
+
finally {
|
|
867
|
+
setActiveScope(prev);
|
|
868
|
+
}
|
|
869
|
+
return ctx;
|
|
870
|
+
});
|
|
871
|
+
// Internal symbols for effect tracking and child management
|
|
872
|
+
ctx[trackSymbol] = (dispose) => effects.push(dispose);
|
|
873
|
+
ctx[childrenSymbol] = children;
|
|
874
|
+
// Register with parent
|
|
875
|
+
if (parent) {
|
|
876
|
+
myIndex = parent[childrenSymbol].push(ctx) - 1;
|
|
877
|
+
}
|
|
878
|
+
// Run initial callback if provided
|
|
879
|
+
if (callback) {
|
|
880
|
+
ctx(callback);
|
|
881
|
+
}
|
|
882
|
+
return ctx;
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Create a simple signal
|
|
887
|
+
*/
|
|
888
|
+
function signal(initialValue) {
|
|
889
|
+
let value = initialValue;
|
|
890
|
+
let deps;
|
|
891
|
+
/**
|
|
892
|
+
* Read the signal value and track dependency
|
|
893
|
+
*/
|
|
894
|
+
const read = () => {
|
|
895
|
+
// === PULL PHASE ===
|
|
896
|
+
// When a computed/effect reads this signal, we register the dependency
|
|
897
|
+
// Fast path: if not tracked or no current computing, skip tracking
|
|
898
|
+
if (tracked && currentComputing !== undefined) {
|
|
899
|
+
// Pass value getter for polling optimization (value revert detection)
|
|
900
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
901
|
+
trackStateDependency((deps ??= new DepsSet(read)), read, value);
|
|
902
|
+
}
|
|
903
|
+
return value;
|
|
904
|
+
// === END PULL PHASE ===
|
|
905
|
+
};
|
|
906
|
+
/**
|
|
907
|
+
* Set a new value and notify dependents
|
|
908
|
+
*/
|
|
909
|
+
read.set = (newValue) => {
|
|
910
|
+
// === PUSH PHASE ===
|
|
911
|
+
// When the signal value changes, we eagerly propagate dirty/check flags
|
|
912
|
+
// to all dependents via markDependents
|
|
913
|
+
warnIfWriteInComputed('signal');
|
|
914
|
+
if (!Object.is(value, newValue)) {
|
|
915
|
+
value = newValue;
|
|
916
|
+
if (deps !== undefined) {
|
|
917
|
+
markDependents(deps); // Push: notify all dependents
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
// === END PUSH PHASE ===
|
|
921
|
+
};
|
|
922
|
+
return read;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Creates a reactive state object
|
|
927
|
+
*/
|
|
928
|
+
function state(object = {}) {
|
|
929
|
+
// State uses a proxy to intercept reads and writes
|
|
930
|
+
// - Reads trigger PULL phase (trackDependency registers the consumer)
|
|
931
|
+
// - Writes trigger PUSH phase (markDependents propagates dirty flags)
|
|
932
|
+
const proxiesCache = new WeakMap();
|
|
933
|
+
/**
|
|
934
|
+
* PUSH PHASE: Notify all dependents that a property changed
|
|
935
|
+
* This propagates dirty/check flags to all live consumers
|
|
936
|
+
*/
|
|
937
|
+
const notifyPropertyDependents = (target, property) => {
|
|
938
|
+
const propsMap = target[propertyDepsSymbol];
|
|
939
|
+
if (!propsMap)
|
|
940
|
+
return;
|
|
941
|
+
const deps = propsMap.get(property);
|
|
942
|
+
if (deps !== undefined) {
|
|
943
|
+
markDependents(deps);
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
const createProxy = (object) => {
|
|
947
|
+
if (proxiesCache.has(object)) {
|
|
948
|
+
return proxiesCache.get(object);
|
|
949
|
+
}
|
|
950
|
+
let methodCache;
|
|
951
|
+
const proxy = new Proxy(object, {
|
|
952
|
+
// PUSH PHASE: Setting a property notifies all dependents
|
|
953
|
+
set(target, p, newValue) {
|
|
954
|
+
warnIfWriteInComputed('state');
|
|
955
|
+
const realValue = unwrapValue(newValue);
|
|
956
|
+
// Use direct property access instead of Reflect for performance
|
|
957
|
+
if (!Object.is(target[p], realValue)) {
|
|
958
|
+
target[p] = realValue;
|
|
959
|
+
// PUSH: Propagate dirty flags to dependents
|
|
960
|
+
notifyPropertyDependents(target, p);
|
|
961
|
+
// Clear method cache entry if it was a method
|
|
962
|
+
methodCache?.delete(p);
|
|
963
|
+
}
|
|
964
|
+
return true;
|
|
965
|
+
},
|
|
966
|
+
// PULL PHASE: Reading a property registers the dependency
|
|
967
|
+
get(target, p) {
|
|
968
|
+
if (p === unwrap)
|
|
969
|
+
return target;
|
|
970
|
+
// Use direct property access instead of Reflect for performance
|
|
971
|
+
const propValue = target[p];
|
|
972
|
+
// PULL: Track dependency if we're inside an effect/computed
|
|
973
|
+
if (tracked && currentComputing !== undefined) {
|
|
974
|
+
// Get or create the Map for this target (stored as non-enumerable property)
|
|
975
|
+
let propsMap = target[propertyDepsSymbol];
|
|
976
|
+
if (propsMap === undefined) {
|
|
977
|
+
propsMap = new Map();
|
|
978
|
+
Object.defineProperty(target, propertyDepsSymbol, { value: propsMap });
|
|
979
|
+
}
|
|
980
|
+
// Get or create the Set for this property
|
|
981
|
+
let deps = propsMap.get(p);
|
|
982
|
+
if (deps === undefined) {
|
|
983
|
+
// Create DepsSet with getter eagerly to avoid V8 field constness deopts
|
|
984
|
+
const propertyGetter = () => target[p];
|
|
985
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
986
|
+
propsMap.set(p, (deps = new DepsSet(propertyGetter)));
|
|
987
|
+
}
|
|
988
|
+
// PULL: Bidirectional linking with optimization
|
|
989
|
+
// Pass value getter for polling optimization (value revert detection)
|
|
990
|
+
// Capture target and property for later value retrieval
|
|
991
|
+
trackStateDependency(deps, deps.b, propValue);
|
|
992
|
+
}
|
|
993
|
+
// Fast path for primitives (most common case)
|
|
994
|
+
const propertyType = typeof propValue;
|
|
995
|
+
if (propValue === null || (propertyType !== 'object' && propertyType !== 'function')) {
|
|
996
|
+
return propValue;
|
|
997
|
+
}
|
|
998
|
+
// Functions are wrapped to apply with correct `this` (target, not proxy)
|
|
999
|
+
// After function call, notify dependents (function may have mutated internal state)
|
|
1000
|
+
// Functions are wrapped to trigger PUSH after mutation
|
|
1001
|
+
if (propertyType === 'function') {
|
|
1002
|
+
// Check cache first to avoid creating new function on every access
|
|
1003
|
+
if (methodCache === undefined) {
|
|
1004
|
+
methodCache = new Map();
|
|
1005
|
+
}
|
|
1006
|
+
let cached = methodCache.get(p);
|
|
1007
|
+
if (cached === undefined) {
|
|
1008
|
+
// Capture method reference at cache time to avoid re-reading on each call
|
|
1009
|
+
const method = propValue;
|
|
1010
|
+
cached = (...args) => {
|
|
1011
|
+
// Unwrap in-place - args is already a new array from rest params
|
|
1012
|
+
for (let i = 0, len = args.length; i < len; ++i) {
|
|
1013
|
+
args[i] = unwrapValue(args[i]);
|
|
1014
|
+
}
|
|
1015
|
+
const result = method.apply(target, args);
|
|
1016
|
+
// PUSH PHASE: Notify after function call (function may have mutated state)
|
|
1017
|
+
// Only notify if we're NOT currently inside an effect/computed execution
|
|
1018
|
+
// to avoid infinite loops when reading during effect
|
|
1019
|
+
if (currentComputing === undefined) {
|
|
1020
|
+
const propsMap = target[propertyDepsSymbol];
|
|
1021
|
+
if (propsMap === undefined)
|
|
1022
|
+
return result;
|
|
1023
|
+
for (const deps of propsMap.values()) {
|
|
1024
|
+
// PUSH: Propagate dirty flags to all property dependents
|
|
1025
|
+
markDependents(deps);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return result;
|
|
1029
|
+
};
|
|
1030
|
+
methodCache.set(p, cached);
|
|
1031
|
+
}
|
|
1032
|
+
return cached;
|
|
1033
|
+
}
|
|
1034
|
+
// Object - create nested proxy
|
|
1035
|
+
return createProxy(propValue);
|
|
1036
|
+
},
|
|
1037
|
+
// PUSH PHASE: Defining a property notifies dependents
|
|
1038
|
+
defineProperty(target, property, attributes) {
|
|
1039
|
+
warnIfWriteInComputed('state');
|
|
1040
|
+
const result = Reflect.defineProperty(target, property, attributes);
|
|
1041
|
+
if (result) {
|
|
1042
|
+
// PUSH: Propagate dirty flags to dependents
|
|
1043
|
+
notifyPropertyDependents(target, property);
|
|
1044
|
+
}
|
|
1045
|
+
return result;
|
|
1046
|
+
},
|
|
1047
|
+
// PUSH PHASE: Deleting a property notifies dependents
|
|
1048
|
+
deleteProperty(target, p) {
|
|
1049
|
+
warnIfWriteInComputed('state');
|
|
1050
|
+
const result = Reflect.deleteProperty(target, p);
|
|
1051
|
+
if (result) {
|
|
1052
|
+
// PUSH: Propagate dirty flags to dependents
|
|
1053
|
+
notifyPropertyDependents(target, p);
|
|
1054
|
+
// Clear method cache entry if it was a method
|
|
1055
|
+
methodCache?.delete(p);
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
},
|
|
1059
|
+
});
|
|
1060
|
+
proxiesCache.set(object, proxy);
|
|
1061
|
+
return proxy;
|
|
1062
|
+
};
|
|
1063
|
+
return createProxy(object);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
export { SUPPRESS_EFFECT_GC_WARNING, WARN_ON_UNTRACKED_EFFECT, WARN_ON_WRITE_IN_COMPUTED, activeScope, computed, debugConfig, effect, flushEffects, scope, setActiveScope, setScheduler, signal, state, untracked, unwrapValue };
|
|
1067
|
+
//# sourceMappingURL=index.mjs.map
|