@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/debug.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { DEV } from 'esm-env';
|
|
2
|
+
|
|
3
|
+
import { currentComputing } from './core';
|
|
4
|
+
import { Flag } from './flags';
|
|
5
|
+
import type { Scope } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Debug configuration flag: Warn when writing to signals/state inside a computed
|
|
9
|
+
*/
|
|
10
|
+
export const WARN_ON_WRITE_IN_COMPUTED = 1 << 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Debug configuration flag: Suppress warning when effects are disposed by GC instead of explicitly
|
|
14
|
+
*/
|
|
15
|
+
export const SUPPRESS_EFFECT_GC_WARNING = 1 << 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Debug configuration flag: Warn when effects are created without an active scope
|
|
19
|
+
* This is an allowed pattern, but teams may choose to enforce scope usage for better effect lifecycle management
|
|
20
|
+
*/
|
|
21
|
+
export const WARN_ON_UNTRACKED_EFFECT = 1 << 2;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Current debug configuration bitfield
|
|
25
|
+
*/
|
|
26
|
+
let debugConfigFlags = 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configure debug behavior using a bitfield of flags
|
|
30
|
+
*/
|
|
31
|
+
export const debugConfig = (flags: number): void => {
|
|
32
|
+
debugConfigFlags = flags | 0;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Safely call each function in an iterable, logging any errors to console
|
|
37
|
+
*/
|
|
38
|
+
export const safeForEach = (fns: Array<() => void>): void => {
|
|
39
|
+
for (let i = 0, len = fns.length; i < len; ++i) {
|
|
40
|
+
const fn = fns[i] as () => void;
|
|
41
|
+
try {
|
|
42
|
+
fn?.();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(e);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Warn if writing inside a computed (not an effect)
|
|
51
|
+
* Only runs in DEV mode and when configured
|
|
52
|
+
*/
|
|
53
|
+
export const warnIfWriteInComputed = (context: string): void => {
|
|
54
|
+
if (DEV && (debugConfigFlags & WARN_ON_WRITE_IN_COMPUTED) !== 0 && currentComputing && (currentComputing.$_flags & Flag.EFFECT) === 0) {
|
|
55
|
+
console.warn(
|
|
56
|
+
`[@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.`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* FinalizationRegistry for detecting effects that are GC'd without being properly disposed.
|
|
63
|
+
* Only created in DEV mode.
|
|
64
|
+
*/
|
|
65
|
+
const effectRegistry: FinalizationRegistry<string> | null = DEV
|
|
66
|
+
? new FinalizationRegistry((stackTrace: string) => {
|
|
67
|
+
if ((debugConfigFlags & SUPPRESS_EFFECT_GC_WARNING) === 0) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[@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}`
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register an effect for GC tracking.
|
|
77
|
+
* Returns a token that must be passed to unregisterEffect when the effect is properly disposed.
|
|
78
|
+
* Only active in DEV mode; returns undefined in production.
|
|
79
|
+
*/
|
|
80
|
+
export const registerEffect: () => object | undefined = DEV
|
|
81
|
+
? () => {
|
|
82
|
+
const token = {};
|
|
83
|
+
// Capture stack trace at effect creation for better debugging
|
|
84
|
+
// Remove the first few lines (Error + registerEffect call) to get to the actual effect() call
|
|
85
|
+
const relevantStack = String(new Error().stack).split('\n').slice(3).join('\n');
|
|
86
|
+
(effectRegistry as FinalizationRegistry<string>).register(token, relevantStack, token);
|
|
87
|
+
return token;
|
|
88
|
+
}
|
|
89
|
+
: () => undefined;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Unregister an effect from GC tracking (called when effect is properly disposed).
|
|
93
|
+
* Only active in DEV mode.
|
|
94
|
+
*/
|
|
95
|
+
export const unregisterEffect: (token: object | undefined) => void = DEV
|
|
96
|
+
? (token: object | undefined) => {
|
|
97
|
+
effectRegistry?.unregister(token as WeakKey);
|
|
98
|
+
}
|
|
99
|
+
: () => {};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Warn if an effect is created without an active scope.
|
|
103
|
+
* Only runs in DEV mode and when WARN_ON_UNTRACKED_EFFECT is enabled.
|
|
104
|
+
*/
|
|
105
|
+
export const warnIfNoActiveScope: (activeScope: Scope | undefined) => void = DEV
|
|
106
|
+
? (activeScope: Scope | undefined) => {
|
|
107
|
+
if ((debugConfigFlags & WARN_ON_UNTRACKED_EFFECT) !== 0 && !activeScope) {
|
|
108
|
+
console.warn(
|
|
109
|
+
`[@slimlib/store] Effect created without an active scope. Consider using scope() or setActiveScope() to track effects for proper lifecycle management.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
: () => {};
|
|
114
|
+
|
|
115
|
+
export const cycleMessage = 'Detected cycle in computations.';
|
package/src/effect.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { batchedAddNew, checkComputedSources, clearSources, DepsSet, noopGetter, runWithTracking, scheduleFlush } from './core';
|
|
2
|
+
import { cycleMessage, registerEffect, unregisterEffect, warnIfNoActiveScope } from './debug';
|
|
3
|
+
import { Flag } from './flags';
|
|
4
|
+
import { activeScope } from './globals';
|
|
5
|
+
import { trackSymbol } from './symbols';
|
|
6
|
+
import type { ReactiveNode } from './internal-types';
|
|
7
|
+
import type { EffectCleanup } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Effect creation counter - increments on every effect creation
|
|
11
|
+
* Used to maintain effect execution order by creation time
|
|
12
|
+
*/
|
|
13
|
+
let effectCreationCounter = 0;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a reactive effect that runs when dependencies change
|
|
17
|
+
*/
|
|
18
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: void is semantically correct here - callback may return nothing or a cleanup function
|
|
19
|
+
export const effect = (callback: () => void | EffectCleanup): (() => void) => {
|
|
20
|
+
let disposed = false;
|
|
21
|
+
|
|
22
|
+
// Register effect for GC tracking (only in DEV mode)
|
|
23
|
+
const gcToken = registerEffect();
|
|
24
|
+
|
|
25
|
+
// Warn if effect is created without an active scope (only in DEV mode when enabled)
|
|
26
|
+
warnIfNoActiveScope(activeScope);
|
|
27
|
+
|
|
28
|
+
// Declare node first so the runner closure can capture it.
|
|
29
|
+
// The variable will be assigned before the runner is ever called.
|
|
30
|
+
let node: ReactiveNode;
|
|
31
|
+
|
|
32
|
+
// Define the runner function BEFORE creating the node so that $_fn
|
|
33
|
+
// is a function from the start (Fix #1: avoids hidden class transition
|
|
34
|
+
// from undefined → function on the $_fn field).
|
|
35
|
+
const runner = () => {
|
|
36
|
+
// Skip if effect was disposed (may still be in batched queue from before disposal)
|
|
37
|
+
if (disposed) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cycle detection: if this node is already being computed, we have a cycle
|
|
42
|
+
const flags = node.$_flags;
|
|
43
|
+
if ((flags & Flag.COMPUTING) !== 0) {
|
|
44
|
+
throw new Error(cycleMessage);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ----------------------------------------------------------------
|
|
48
|
+
// PULL PHASE: Verify if sources actually changed before running
|
|
49
|
+
// ----------------------------------------------------------------
|
|
50
|
+
// Bail-out optimization: if only CHECK flag is set (not DIRTY),
|
|
51
|
+
// verify that computed sources actually changed before running
|
|
52
|
+
if ((flags & (Flag.DIRTY | Flag.CHECK | Flag.HAS_STATE_SOURCE)) === Flag.CHECK) {
|
|
53
|
+
// PULL: Read computed sources to check if they changed
|
|
54
|
+
// If false, sources didn't change - clear CHECK flag and skip
|
|
55
|
+
// If true, sources changed or errored - proceed to run
|
|
56
|
+
if (!checkComputedSources(node.$_sources)) {
|
|
57
|
+
node.$_flags = flags & ~Flag.CHECK;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ----------------------------------------------------------------
|
|
63
|
+
// PULL PHASE: Execute effect and track dependencies
|
|
64
|
+
// ----------------------------------------------------------------
|
|
65
|
+
runWithTracking(node, () => {
|
|
66
|
+
// Run previous cleanup if it exists (stored in $_value)
|
|
67
|
+
if (typeof node.$_value === 'function') {
|
|
68
|
+
(node.$_value as EffectCleanup)();
|
|
69
|
+
}
|
|
70
|
+
// Run the callback and store new cleanup in $_value
|
|
71
|
+
// (callback will PULL values from signals/state/computed)
|
|
72
|
+
node.$_value = callback();
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Create effect node as a plain object with IDENTICAL initial field types
|
|
77
|
+
// as computed nodes to ensure V8 hidden class monomorphism (Fix #2):
|
|
78
|
+
// $_deps: new DepsSet() (Set object, same as computed — never used for effects)
|
|
79
|
+
// $_fn: runner (function, same as computed's getter)
|
|
80
|
+
// $_equals: Object.is (function, same as computed's equality comparator)
|
|
81
|
+
//
|
|
82
|
+
// $_value: stores cleanup function returned by the effect callback
|
|
83
|
+
// $_stamp: creation order counter for effect scheduling
|
|
84
|
+
node = {
|
|
85
|
+
$_sources: [],
|
|
86
|
+
$_deps: new DepsSet<ReactiveNode>(noopGetter),
|
|
87
|
+
$_flags: Flag.DIRTY | Flag.EFFECT,
|
|
88
|
+
$_skipped: 0,
|
|
89
|
+
$_version: 0,
|
|
90
|
+
$_value: undefined as unknown,
|
|
91
|
+
$_stamp: ++effectCreationCounter,
|
|
92
|
+
$_fn: runner,
|
|
93
|
+
$_equals: Object.is,
|
|
94
|
+
} as unknown as ReactiveNode;
|
|
95
|
+
|
|
96
|
+
const effectId = node.$_stamp;
|
|
97
|
+
|
|
98
|
+
const dispose = (): void => {
|
|
99
|
+
// Mark as disposed to prevent running if still in batched queue
|
|
100
|
+
disposed = true;
|
|
101
|
+
// Unregister from GC tracking (only in DEV mode)
|
|
102
|
+
unregisterEffect(gcToken);
|
|
103
|
+
// Run cleanup if it exists (stored in $_value)
|
|
104
|
+
if (typeof node.$_value === 'function') {
|
|
105
|
+
(node.$_value as EffectCleanup)();
|
|
106
|
+
}
|
|
107
|
+
clearSources(node);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Track to appropriate scope
|
|
111
|
+
if (activeScope) {
|
|
112
|
+
(activeScope[trackSymbol] as (dispose: () => void) => void)(dispose);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ----------------------------------------------------------------
|
|
116
|
+
// Initial scheduling (triggers first PULL when flush runs)
|
|
117
|
+
// ----------------------------------------------------------------
|
|
118
|
+
// Trigger first run via batched queue
|
|
119
|
+
// node is already dirty
|
|
120
|
+
// and effect is for sure with the latest id so we directly adding without the sort
|
|
121
|
+
batchedAddNew(node, effectId);
|
|
122
|
+
scheduleFlush();
|
|
123
|
+
|
|
124
|
+
return dispose;
|
|
125
|
+
};
|
package/src/flags.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// BIT FLAGS FOR NODE STATE
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// These flags are central to the push/pull reactive algorithm:
|
|
5
|
+
// - PUSH PHASE sets: Flag.CHECK, Flag.DIRTY (propagated eagerly on source change)
|
|
6
|
+
// - PULL PHASE checks: Flag.NEEDS_WORK to decide if recomputation is needed
|
|
7
|
+
// - PULL PHASE clears: Flag.DIRTY, Flag.CHECK after recomputation
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
// biome-ignore lint/suspicious/noConstEnum: optimization
|
|
11
|
+
export const enum Flag {
|
|
12
|
+
// PUSH PHASE: Set when a source definitely changed - forces recomputation
|
|
13
|
+
DIRTY = 1 << 0, // 1 - definitely needs recomputation
|
|
14
|
+
|
|
15
|
+
// PUSH PHASE: Set when a source might have changed - needs verification
|
|
16
|
+
CHECK = 1 << 1, // 2 - might need recomputation, check sources first
|
|
17
|
+
|
|
18
|
+
// PULL PHASE: Set while executing getter to detect cycles
|
|
19
|
+
COMPUTING = 1 << 2, // 4 - currently executing
|
|
20
|
+
|
|
21
|
+
// Determines if node receives PUSH notifications (effects always do)
|
|
22
|
+
EFFECT = 1 << 3, // 8 - is an effect (eager execution, always live)
|
|
23
|
+
|
|
24
|
+
// PULL PHASE: Indicates cached value is available
|
|
25
|
+
HAS_VALUE = 1 << 4, // 16 - has a cached value
|
|
26
|
+
|
|
27
|
+
// PULL PHASE: Indicates cached error is available
|
|
28
|
+
HAS_ERROR = 1 << 5, // 32 - has a cached error (per TC39 Signals proposal)
|
|
29
|
+
|
|
30
|
+
// PUSH PHASE: When set, node receives push notifications from sources
|
|
31
|
+
LIVE = 1 << 6, // 64 - computed is live (has live dependents)
|
|
32
|
+
|
|
33
|
+
// PULL PHASE: Has at least one state/signal source (requires polling, can't skip loop)
|
|
34
|
+
HAS_STATE_SOURCE = 1 << 7, // 128 - has state/signal dependency
|
|
35
|
+
|
|
36
|
+
// PULL PHASE: Has at least one computed source (requires version update loop)
|
|
37
|
+
HAS_COMPUTED_SOURCE = 1 << 8, // 256 - has computed dependency
|
|
38
|
+
}
|
package/src/globals.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Scope } from './types';
|
|
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
|
+
export let activeScope: Scope | undefined;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Set the active scope for effect tracking
|
|
12
|
+
* Effects created outside of a scope() callback will be tracked to this scope
|
|
13
|
+
* Pass undefined to clear the active scope
|
|
14
|
+
*/
|
|
15
|
+
export const setActiveScope = (scope: Scope | undefined): void => {
|
|
16
|
+
activeScope = scope;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Scheduler function used to schedule effect execution
|
|
21
|
+
* Defaults to queueMicrotask, can be replaced with setScheduler
|
|
22
|
+
*/
|
|
23
|
+
export let scheduler: (callback: () => void) => void = queueMicrotask;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Set a custom scheduler function for effect execution
|
|
27
|
+
*/
|
|
28
|
+
export const setScheduler = (newScheduler: (callback: () => void) => void): void => {
|
|
29
|
+
scheduler = newScheduler;
|
|
30
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { computed } from './computed';
|
|
2
|
+
export { flushEffects, untracked, unwrapValue } from './core';
|
|
3
|
+
export { debugConfig, SUPPRESS_EFFECT_GC_WARNING, WARN_ON_UNTRACKED_EFFECT, WARN_ON_WRITE_IN_COMPUTED } from './debug';
|
|
4
|
+
export { effect } from './effect';
|
|
5
|
+
export { activeScope, setActiveScope, setScheduler } from './globals';
|
|
6
|
+
export { scope } from './scope';
|
|
7
|
+
export { signal } from './signal';
|
|
8
|
+
export { state } from './state';
|
|
9
|
+
export type { Computed, Effect, EffectCleanup, OnDisposeCallback, Scope, ScopeCallback, ScopeFunction, Signal } from './types';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal types used for implementation - not part of public API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Source entry for dependencies (unified for monomorphism).
|
|
7
|
+
* Properties are initialized for both types to ensure consistent hidden class.
|
|
8
|
+
*/
|
|
9
|
+
export type SourceEntry = {
|
|
10
|
+
$_dependents: Set<ReactiveNode>;
|
|
11
|
+
$_node: ReactiveNode | undefined;
|
|
12
|
+
$_version: number;
|
|
13
|
+
$_getter: undefined | (() => unknown);
|
|
14
|
+
$_storedValue: unknown;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Base type for reactive nodes (computed and effect).
|
|
19
|
+
* Uses $_ prefixed properties for minification.
|
|
20
|
+
*
|
|
21
|
+
* Both computed and effect nodes are plain objects and MUST initialize ALL
|
|
22
|
+
* of these properties in the same order to ensure V8 hidden class monomorphism.
|
|
23
|
+
* Property initialization order:
|
|
24
|
+
* $_sources, $_deps, $_flags, $_skipped, $_version,
|
|
25
|
+
* $_value, $_stamp, $_fn, $_equals
|
|
26
|
+
*
|
|
27
|
+
* Several fields serve different purposes depending on node type:
|
|
28
|
+
* $_value — Computed: cached value or thrown error. Effect: cleanup function.
|
|
29
|
+
* $_stamp — Computed: last seen globalVersion (fast-path cache). Effect: creation order (scheduling).
|
|
30
|
+
* $_fn — Computed: getter function. Effect: runner function.
|
|
31
|
+
* $_equals — Computed: equality comparator. Effect: Object.is (unused, for hidden class monomorphism).
|
|
32
|
+
* $_deps — Computed: set of dependent consumers. Effect: empty DepsSet (unused, for hidden class monomorphism).
|
|
33
|
+
* $_version — Computed: value change counter. Effect: 0 (unused).
|
|
34
|
+
*/
|
|
35
|
+
export type ReactiveNode = {
|
|
36
|
+
$_sources: SourceEntry[];
|
|
37
|
+
$_deps: Set<ReactiveNode>;
|
|
38
|
+
$_flags: number;
|
|
39
|
+
$_skipped: number;
|
|
40
|
+
$_version: number;
|
|
41
|
+
$_value: unknown;
|
|
42
|
+
$_stamp: number;
|
|
43
|
+
$_fn: (() => unknown) | undefined;
|
|
44
|
+
$_equals: ((a: unknown, b: unknown) => boolean) | undefined;
|
|
45
|
+
};
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { safeForEach } from './debug';
|
|
2
|
+
import { activeScope, setActiveScope } from './globals';
|
|
3
|
+
import { childrenSymbol, trackSymbol } from './symbols';
|
|
4
|
+
import type { OnDisposeCallback, Scope, ScopeCallback } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a reactive scope for tracking effects
|
|
8
|
+
* Effects created within a scope callback are automatically tracked and disposed together
|
|
9
|
+
*/
|
|
10
|
+
export const scope = (callback?: ScopeCallback, parent: Scope | undefined | null = activeScope): Scope => {
|
|
11
|
+
const effects: (() => void)[] = [];
|
|
12
|
+
const children: Scope[] = [];
|
|
13
|
+
const cleanups: Array<() => void> = [];
|
|
14
|
+
let disposed = false;
|
|
15
|
+
let myIndex = -1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Register a cleanup function to run when scope is disposed
|
|
19
|
+
*/
|
|
20
|
+
const onDispose: OnDisposeCallback = cleanup => {
|
|
21
|
+
if (disposed) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
cleanups.push(cleanup);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const ctx = ((cb?: ScopeCallback) => {
|
|
28
|
+
if (!cb) {
|
|
29
|
+
// Dispose - return early if already disposed (idempotent)
|
|
30
|
+
if (disposed) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Dispose
|
|
34
|
+
disposed = true;
|
|
35
|
+
|
|
36
|
+
// Dispose children first (depth-first)
|
|
37
|
+
safeForEach(children);
|
|
38
|
+
|
|
39
|
+
// Stop all effects
|
|
40
|
+
safeForEach(effects);
|
|
41
|
+
effects.length = 0;
|
|
42
|
+
|
|
43
|
+
// Run cleanup handlers
|
|
44
|
+
safeForEach(cleanups);
|
|
45
|
+
|
|
46
|
+
// Remove from parent
|
|
47
|
+
if (parent) {
|
|
48
|
+
(parent[childrenSymbol] as (Scope | undefined)[])[myIndex] = undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extend scope - silently ignore if disposed
|
|
55
|
+
if (disposed) {
|
|
56
|
+
return ctx;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Run callback in this scope's context
|
|
60
|
+
const prev = activeScope;
|
|
61
|
+
setActiveScope(ctx);
|
|
62
|
+
try {
|
|
63
|
+
cb(onDispose);
|
|
64
|
+
} finally {
|
|
65
|
+
setActiveScope(prev);
|
|
66
|
+
}
|
|
67
|
+
return ctx;
|
|
68
|
+
}) as Scope;
|
|
69
|
+
|
|
70
|
+
// Internal symbols for effect tracking and child management
|
|
71
|
+
ctx[trackSymbol] = (dispose: () => void) => effects.push(dispose);
|
|
72
|
+
ctx[childrenSymbol] = children;
|
|
73
|
+
|
|
74
|
+
// Register with parent
|
|
75
|
+
if (parent) {
|
|
76
|
+
myIndex = (parent[childrenSymbol] as Scope[]).push(ctx) - 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Run initial callback if provided
|
|
80
|
+
if (callback) {
|
|
81
|
+
ctx(callback);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return ctx;
|
|
85
|
+
};
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { currentComputing, DepsSet, markDependents, tracked, trackStateDependency } from './core';
|
|
2
|
+
import { warnIfWriteInComputed } from './debug';
|
|
3
|
+
import type { ReactiveNode } from './internal-types';
|
|
4
|
+
import type { Signal } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a simple signal without an initial value
|
|
8
|
+
*/
|
|
9
|
+
export function signal<T>(): Signal<T | undefined>;
|
|
10
|
+
/**
|
|
11
|
+
* Create a simple signal with an initial value
|
|
12
|
+
*/
|
|
13
|
+
export function signal<T>(initialValue: T): Signal<T>;
|
|
14
|
+
/**
|
|
15
|
+
* Create a simple signal
|
|
16
|
+
*/
|
|
17
|
+
export function signal<T>(initialValue?: T): Signal<T> {
|
|
18
|
+
let value = initialValue as T;
|
|
19
|
+
let deps: DepsSet<ReactiveNode> | undefined;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read the signal value and track dependency
|
|
23
|
+
*/
|
|
24
|
+
const read = (): T => {
|
|
25
|
+
// === PULL PHASE ===
|
|
26
|
+
// When a computed/effect reads this signal, we register the dependency
|
|
27
|
+
// Fast path: if not tracked or no current computing, skip tracking
|
|
28
|
+
if (tracked && currentComputing !== undefined) {
|
|
29
|
+
// Pass value getter for polling optimization (value revert detection)
|
|
30
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
31
|
+
trackStateDependency((deps ??= new DepsSet<ReactiveNode>(read)), read, value);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
// === END PULL PHASE ===
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Set a new value and notify dependents
|
|
39
|
+
*/
|
|
40
|
+
read.set = (newValue: T): void => {
|
|
41
|
+
// === PUSH PHASE ===
|
|
42
|
+
// When the signal value changes, we eagerly propagate dirty/check flags
|
|
43
|
+
// to all dependents via markDependents
|
|
44
|
+
warnIfWriteInComputed('signal');
|
|
45
|
+
if (!Object.is(value, newValue)) {
|
|
46
|
+
value = newValue;
|
|
47
|
+
if (deps !== undefined) {
|
|
48
|
+
markDependents(deps); // Push: notify all dependents
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// === END PUSH PHASE ===
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return read;
|
|
55
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { currentComputing, DepsSet, markDependents, tracked, trackStateDependency, unwrapValue } from './core';
|
|
2
|
+
import { warnIfWriteInComputed } from './debug';
|
|
3
|
+
import { propertyDepsSymbol, unwrap } from './symbols';
|
|
4
|
+
import type { ReactiveNode } from './internal-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a store without an initial object
|
|
8
|
+
*/
|
|
9
|
+
export function state(): object;
|
|
10
|
+
/**
|
|
11
|
+
* Creates a store with an initial object
|
|
12
|
+
*/
|
|
13
|
+
export function state<T extends object>(object: T): T;
|
|
14
|
+
/**
|
|
15
|
+
* Creates a reactive state object
|
|
16
|
+
*/
|
|
17
|
+
export function state<T extends object>(object: T = {} as T): T {
|
|
18
|
+
// State uses a proxy to intercept reads and writes
|
|
19
|
+
// - Reads trigger PULL phase (trackDependency registers the consumer)
|
|
20
|
+
// - Writes trigger PUSH phase (markDependents propagates dirty flags)
|
|
21
|
+
const proxiesCache = new WeakMap();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* PUSH PHASE: Notify all dependents that a property changed
|
|
25
|
+
* This propagates dirty/check flags to all live consumers
|
|
26
|
+
*/
|
|
27
|
+
const notifyPropertyDependents = (target: object, property: string | symbol): void => {
|
|
28
|
+
const propsMap = (target as Record<symbol, unknown>)[propertyDepsSymbol] as Map<string | symbol, DepsSet<ReactiveNode>> | undefined;
|
|
29
|
+
if (!propsMap) return;
|
|
30
|
+
const deps = propsMap.get(property);
|
|
31
|
+
if (deps !== undefined) {
|
|
32
|
+
markDependents(deps as DepsSet<ReactiveNode>);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const createProxy = <U extends object>(object: U): U => {
|
|
37
|
+
if (proxiesCache.has(object)) {
|
|
38
|
+
return proxiesCache.get(object) as U;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let methodCache: Map<string | symbol, (...args: unknown[]) => unknown> | undefined;
|
|
42
|
+
|
|
43
|
+
const proxy = new Proxy(object, {
|
|
44
|
+
// PUSH PHASE: Setting a property notifies all dependents
|
|
45
|
+
set(target, p, newValue) {
|
|
46
|
+
warnIfWriteInComputed('state');
|
|
47
|
+
const realValue = unwrapValue(newValue);
|
|
48
|
+
// Use direct property access instead of Reflect for performance
|
|
49
|
+
if (!Object.is((target as Record<string | symbol, unknown>)[p], realValue)) {
|
|
50
|
+
(target as Record<string | symbol, unknown>)[p] = realValue;
|
|
51
|
+
// PUSH: Propagate dirty flags to dependents
|
|
52
|
+
notifyPropertyDependents(target, p);
|
|
53
|
+
// Clear method cache entry if it was a method
|
|
54
|
+
methodCache?.delete(p);
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
},
|
|
58
|
+
// PULL PHASE: Reading a property registers the dependency
|
|
59
|
+
get(target, p) {
|
|
60
|
+
if (p === unwrap) return target;
|
|
61
|
+
// Use direct property access instead of Reflect for performance
|
|
62
|
+
const propValue = (target as Record<string | symbol, unknown>)[p];
|
|
63
|
+
|
|
64
|
+
// PULL: Track dependency if we're inside an effect/computed
|
|
65
|
+
if (tracked && currentComputing !== undefined) {
|
|
66
|
+
// Get or create the Map for this target (stored as non-enumerable property)
|
|
67
|
+
let propsMap = (target as Record<symbol, unknown>)[propertyDepsSymbol] as
|
|
68
|
+
| Map<string | symbol, DepsSet<ReactiveNode>>
|
|
69
|
+
| undefined;
|
|
70
|
+
if (propsMap === undefined) {
|
|
71
|
+
propsMap = new Map();
|
|
72
|
+
Object.defineProperty(target, propertyDepsSymbol, { value: propsMap });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get or create the Set for this property
|
|
76
|
+
let deps = propsMap.get(p);
|
|
77
|
+
|
|
78
|
+
if (deps === undefined) {
|
|
79
|
+
// Create DepsSet with getter eagerly to avoid V8 field constness deopts
|
|
80
|
+
const propertyGetter = () => (target as Record<string | symbol, unknown>)[p];
|
|
81
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: optimization
|
|
82
|
+
propsMap.set(p, (deps = new DepsSet<ReactiveNode>(propertyGetter)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// PULL: Bidirectional linking with optimization
|
|
86
|
+
// Pass value getter for polling optimization (value revert detection)
|
|
87
|
+
// Capture target and property for later value retrieval
|
|
88
|
+
trackStateDependency(
|
|
89
|
+
deps as DepsSet<ReactiveNode>,
|
|
90
|
+
(deps as DepsSet<ReactiveNode>).$_getter as () => unknown,
|
|
91
|
+
propValue
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fast path for primitives (most common case)
|
|
96
|
+
const propertyType = typeof propValue;
|
|
97
|
+
if (propValue === null || (propertyType !== 'object' && propertyType !== 'function')) {
|
|
98
|
+
return propValue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Functions are wrapped to apply with correct `this` (target, not proxy)
|
|
102
|
+
// After function call, notify dependents (function may have mutated internal state)
|
|
103
|
+
// Functions are wrapped to trigger PUSH after mutation
|
|
104
|
+
if (propertyType === 'function') {
|
|
105
|
+
// Check cache first to avoid creating new function on every access
|
|
106
|
+
if (methodCache === undefined) {
|
|
107
|
+
methodCache = new Map<string | symbol, (...args: unknown[]) => unknown>();
|
|
108
|
+
}
|
|
109
|
+
let cached = methodCache.get(p);
|
|
110
|
+
if (cached === undefined) {
|
|
111
|
+
// Capture method reference at cache time to avoid re-reading on each call
|
|
112
|
+
const method = propValue as (...args: unknown[]) => unknown;
|
|
113
|
+
cached = (...args: unknown[]) => {
|
|
114
|
+
// Unwrap in-place - args is already a new array from rest params
|
|
115
|
+
for (let i = 0, len = args.length; i < len; ++i) {
|
|
116
|
+
args[i] = unwrapValue(args[i]);
|
|
117
|
+
}
|
|
118
|
+
const result = method.apply(target, args);
|
|
119
|
+
// PUSH PHASE: Notify after function call (function may have mutated state)
|
|
120
|
+
// Only notify if we're NOT currently inside an effect/computed execution
|
|
121
|
+
// to avoid infinite loops when reading during effect
|
|
122
|
+
if (currentComputing === undefined) {
|
|
123
|
+
const propsMap = (target as Record<symbol, unknown>)[propertyDepsSymbol] as
|
|
124
|
+
| Map<string | symbol, DepsSet<ReactiveNode>>
|
|
125
|
+
| undefined;
|
|
126
|
+
if (propsMap === undefined) return result;
|
|
127
|
+
for (const deps of propsMap.values()) {
|
|
128
|
+
// PUSH: Propagate dirty flags to all property dependents
|
|
129
|
+
markDependents(deps);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
};
|
|
134
|
+
methodCache.set(p, cached);
|
|
135
|
+
}
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Object - create nested proxy
|
|
140
|
+
return createProxy(propValue as object);
|
|
141
|
+
},
|
|
142
|
+
// PUSH PHASE: Defining a property notifies dependents
|
|
143
|
+
defineProperty(target, property, attributes) {
|
|
144
|
+
warnIfWriteInComputed('state');
|
|
145
|
+
const result = Reflect.defineProperty(target, property, attributes);
|
|
146
|
+
if (result) {
|
|
147
|
+
// PUSH: Propagate dirty flags to dependents
|
|
148
|
+
notifyPropertyDependents(target, property);
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
// PUSH PHASE: Deleting a property notifies dependents
|
|
153
|
+
deleteProperty(target, p) {
|
|
154
|
+
warnIfWriteInComputed('state');
|
|
155
|
+
const result = Reflect.deleteProperty(target, p);
|
|
156
|
+
if (result) {
|
|
157
|
+
// PUSH: Propagate dirty flags to dependents
|
|
158
|
+
notifyPropertyDependents(target, p);
|
|
159
|
+
// Clear method cache entry if it was a method
|
|
160
|
+
methodCache?.delete(p);
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
proxiesCache.set(object, proxy);
|
|
166
|
+
return proxy as U;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return createProxy(object);
|
|
170
|
+
}
|