@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.
Files changed (57) hide show
  1. package/README.md +700 -129
  2. package/dist/index.mjs +1067 -1
  3. package/dist/index.mjs.map +1 -0
  4. package/package.json +9 -69
  5. package/src/computed.ts +232 -0
  6. package/src/core.ts +434 -0
  7. package/src/debug.ts +115 -0
  8. package/src/effect.ts +125 -0
  9. package/src/flags.ts +38 -0
  10. package/src/globals.ts +30 -0
  11. package/src/index.ts +9 -0
  12. package/src/internal-types.ts +45 -0
  13. package/src/scope.ts +85 -0
  14. package/src/signal.ts +55 -0
  15. package/src/state.ts +170 -0
  16. package/src/symbols.ts +9 -0
  17. package/src/types.ts +47 -0
  18. package/types/index.d.ts +129 -0
  19. package/types/index.d.ts.map +52 -0
  20. package/angular/package.json +0 -5
  21. package/core/package.json +0 -5
  22. package/dist/angular.cjs +0 -37
  23. package/dist/angular.d.ts +0 -23
  24. package/dist/angular.mjs +0 -33
  25. package/dist/angular.umd.js +0 -2
  26. package/dist/angular.umd.js.map +0 -1
  27. package/dist/core.cjs +0 -79
  28. package/dist/core.d.ts +0 -8
  29. package/dist/core.mjs +0 -76
  30. package/dist/index.cjs +0 -8
  31. package/dist/index.d.ts +0 -1
  32. package/dist/index.umd.js +0 -2
  33. package/dist/index.umd.js.map +0 -1
  34. package/dist/preact.cjs +0 -16
  35. package/dist/preact.d.ts +0 -3
  36. package/dist/preact.mjs +0 -13
  37. package/dist/preact.umd.js +0 -2
  38. package/dist/preact.umd.js.map +0 -1
  39. package/dist/react.cjs +0 -16
  40. package/dist/react.d.ts +0 -3
  41. package/dist/react.mjs +0 -13
  42. package/dist/react.umd.js +0 -2
  43. package/dist/react.umd.js.map +0 -1
  44. package/dist/rxjs.cjs +0 -18
  45. package/dist/rxjs.d.ts +0 -3
  46. package/dist/rxjs.mjs +0 -15
  47. package/dist/rxjs.umd.js +0 -2
  48. package/dist/rxjs.umd.js.map +0 -1
  49. package/dist/svelte.cjs +0 -7
  50. package/dist/svelte.d.ts +0 -1
  51. package/dist/svelte.mjs +0 -5
  52. package/dist/svelte.umd.js +0 -2
  53. package/dist/svelte.umd.js.map +0 -1
  54. package/preact/package.json +0 -5
  55. package/react/package.json +0 -5
  56. package/rxjs/package.json +0 -5
  57. 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
+ }