signalium 1.0.2 → 1.1.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.
Files changed (138) hide show
  1. package/.turbo/turbo-build.log +3 -3
  2. package/CHANGELOG.md +18 -0
  3. package/dist/cjs/config.d.ts +2 -2
  4. package/dist/cjs/config.d.ts.map +1 -1
  5. package/dist/cjs/config.js.map +1 -1
  6. package/dist/cjs/hooks.d.ts.map +1 -1
  7. package/dist/cjs/hooks.js +14 -2
  8. package/dist/cjs/hooks.js.map +1 -1
  9. package/dist/cjs/index.d.ts +1 -1
  10. package/dist/cjs/index.d.ts.map +1 -1
  11. package/dist/cjs/index.js +2 -1
  12. package/dist/cjs/index.js.map +1 -1
  13. package/dist/cjs/internals/async.d.ts +1 -1
  14. package/dist/cjs/internals/async.d.ts.map +1 -1
  15. package/dist/cjs/internals/async.js +20 -15
  16. package/dist/cjs/internals/async.js.map +1 -1
  17. package/dist/cjs/internals/connect.d.ts.map +1 -1
  18. package/dist/cjs/internals/connect.js +6 -0
  19. package/dist/cjs/internals/connect.js.map +1 -1
  20. package/dist/cjs/internals/contexts.d.ts +9 -2
  21. package/dist/cjs/internals/contexts.d.ts.map +1 -1
  22. package/dist/cjs/internals/contexts.js +37 -3
  23. package/dist/cjs/internals/contexts.js.map +1 -1
  24. package/dist/cjs/internals/derived.d.ts +15 -5
  25. package/dist/cjs/internals/derived.d.ts.map +1 -1
  26. package/dist/cjs/internals/derived.js +11 -11
  27. package/dist/cjs/internals/derived.js.map +1 -1
  28. package/dist/cjs/internals/get.js +2 -2
  29. package/dist/cjs/internals/get.js.map +1 -1
  30. package/dist/cjs/internals/scheduling.d.ts +2 -0
  31. package/dist/cjs/internals/scheduling.d.ts.map +1 -1
  32. package/dist/cjs/internals/scheduling.js +16 -1
  33. package/dist/cjs/internals/scheduling.js.map +1 -1
  34. package/dist/cjs/internals/state.d.ts +2 -2
  35. package/dist/cjs/internals/state.d.ts.map +1 -1
  36. package/dist/cjs/internals/utils/equals.d.ts +2 -0
  37. package/dist/cjs/internals/utils/equals.d.ts.map +1 -1
  38. package/dist/cjs/internals/utils/equals.js +5 -3
  39. package/dist/cjs/internals/utils/equals.js.map +1 -1
  40. package/dist/cjs/react/context.d.ts.map +1 -1
  41. package/dist/cjs/react/context.js +5 -0
  42. package/dist/cjs/react/context.js.map +1 -1
  43. package/dist/cjs/react/provider.d.ts +2 -3
  44. package/dist/cjs/react/provider.d.ts.map +1 -1
  45. package/dist/cjs/react/provider.js +2 -6
  46. package/dist/cjs/react/provider.js.map +1 -1
  47. package/dist/cjs/react/rendering.d.ts +2 -0
  48. package/dist/cjs/react/rendering.d.ts.map +1 -0
  49. package/dist/cjs/react/rendering.js +25 -0
  50. package/dist/cjs/react/rendering.js.map +1 -0
  51. package/dist/cjs/react/signal-value.d.ts +1 -1
  52. package/dist/cjs/react/signal-value.d.ts.map +1 -1
  53. package/dist/cjs/react/signal-value.js +4 -53
  54. package/dist/cjs/react/signal-value.js.map +1 -1
  55. package/dist/cjs/react/state.d.ts +2 -2
  56. package/dist/cjs/react/state.d.ts.map +1 -1
  57. package/dist/cjs/react/state.js.map +1 -1
  58. package/dist/cjs/types.d.ts +13 -1
  59. package/dist/cjs/types.d.ts.map +1 -1
  60. package/dist/esm/config.d.ts +2 -2
  61. package/dist/esm/config.d.ts.map +1 -1
  62. package/dist/esm/config.js.map +1 -1
  63. package/dist/esm/hooks.d.ts.map +1 -1
  64. package/dist/esm/hooks.js +14 -2
  65. package/dist/esm/hooks.js.map +1 -1
  66. package/dist/esm/index.d.ts +1 -1
  67. package/dist/esm/index.d.ts.map +1 -1
  68. package/dist/esm/index.js +1 -1
  69. package/dist/esm/index.js.map +1 -1
  70. package/dist/esm/internals/async.d.ts +1 -1
  71. package/dist/esm/internals/async.d.ts.map +1 -1
  72. package/dist/esm/internals/async.js +21 -16
  73. package/dist/esm/internals/async.js.map +1 -1
  74. package/dist/esm/internals/connect.d.ts.map +1 -1
  75. package/dist/esm/internals/connect.js +6 -0
  76. package/dist/esm/internals/connect.js.map +1 -1
  77. package/dist/esm/internals/contexts.d.ts +9 -2
  78. package/dist/esm/internals/contexts.d.ts.map +1 -1
  79. package/dist/esm/internals/contexts.js +35 -3
  80. package/dist/esm/internals/contexts.js.map +1 -1
  81. package/dist/esm/internals/derived.d.ts +15 -5
  82. package/dist/esm/internals/derived.d.ts.map +1 -1
  83. package/dist/esm/internals/derived.js +11 -11
  84. package/dist/esm/internals/derived.js.map +1 -1
  85. package/dist/esm/internals/get.js +2 -2
  86. package/dist/esm/internals/get.js.map +1 -1
  87. package/dist/esm/internals/scheduling.d.ts +2 -0
  88. package/dist/esm/internals/scheduling.d.ts.map +1 -1
  89. package/dist/esm/internals/scheduling.js +14 -0
  90. package/dist/esm/internals/scheduling.js.map +1 -1
  91. package/dist/esm/internals/state.d.ts +2 -2
  92. package/dist/esm/internals/state.d.ts.map +1 -1
  93. package/dist/esm/internals/utils/equals.d.ts +2 -0
  94. package/dist/esm/internals/utils/equals.d.ts.map +1 -1
  95. package/dist/esm/internals/utils/equals.js +2 -2
  96. package/dist/esm/internals/utils/equals.js.map +1 -1
  97. package/dist/esm/react/context.d.ts.map +1 -1
  98. package/dist/esm/react/context.js +5 -0
  99. package/dist/esm/react/context.js.map +1 -1
  100. package/dist/esm/react/provider.d.ts +2 -3
  101. package/dist/esm/react/provider.d.ts.map +1 -1
  102. package/dist/esm/react/provider.js +3 -7
  103. package/dist/esm/react/provider.js.map +1 -1
  104. package/dist/esm/react/rendering.d.ts +2 -0
  105. package/dist/esm/react/rendering.d.ts.map +1 -0
  106. package/dist/esm/react/rendering.js +19 -0
  107. package/dist/esm/react/rendering.js.map +1 -0
  108. package/dist/esm/react/signal-value.d.ts +1 -1
  109. package/dist/esm/react/signal-value.d.ts.map +1 -1
  110. package/dist/esm/react/signal-value.js +2 -18
  111. package/dist/esm/react/signal-value.js.map +1 -1
  112. package/dist/esm/react/state.d.ts +2 -2
  113. package/dist/esm/react/state.d.ts.map +1 -1
  114. package/dist/esm/react/state.js.map +1 -1
  115. package/dist/esm/types.d.ts +13 -1
  116. package/dist/esm/types.d.ts.map +1 -1
  117. package/package.json +1 -1
  118. package/src/__tests__/context.test.ts +103 -1
  119. package/src/__tests__/gc.test.ts +256 -0
  120. package/src/__tests__/utils/instrumented-hooks.ts +8 -11
  121. package/src/config.ts +5 -3
  122. package/src/hooks.ts +17 -3
  123. package/src/index.ts +8 -1
  124. package/src/internals/async.ts +13 -8
  125. package/src/internals/connect.ts +8 -0
  126. package/src/internals/contexts.ts +45 -5
  127. package/src/internals/derived.ts +25 -16
  128. package/src/internals/get.ts +2 -2
  129. package/src/internals/scheduling.ts +20 -0
  130. package/src/internals/state.ts +3 -3
  131. package/src/internals/utils/equals.ts +2 -2
  132. package/src/react/__tests__/contexts.test.tsx +84 -2
  133. package/src/react/context.ts +6 -0
  134. package/src/react/provider.tsx +4 -12
  135. package/src/react/rendering.ts +25 -0
  136. package/src/react/signal-value.ts +3 -26
  137. package/src/react/state.ts +3 -3
  138. package/src/types.ts +15 -1
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { reactive, createContext, withContexts, watcher, state } from '../index.js';
3
+ import { SignalScope, ROOT_SCOPE, forceGc, clearRootScope } from '../internals/contexts.js';
4
+ import { nextTick, sleep } from './utils/async.js';
5
+
6
+ // Helper to access private properties for testing
7
+ const getSignalsMap = (scope: SignalScope) => {
8
+ return (scope as any).signals as Map<number, any>;
9
+ };
10
+
11
+ const getGCCandidates = (scope: SignalScope) => {
12
+ return (scope as any).gcCandidates as Set<any>;
13
+ };
14
+
15
+ describe('Garbage Collection', () => {
16
+ beforeEach(() => {
17
+ clearRootScope();
18
+ });
19
+
20
+ it('should automatically garbage collect unwatched signals', async () => {
21
+ const testSignal = reactive(() => 42);
22
+
23
+ const w = watcher(() => testSignal());
24
+
25
+ // Watch the signal
26
+ const unwatch = w.addListener(() => {
27
+ testSignal();
28
+ });
29
+
30
+ await nextTick();
31
+
32
+ // Signal should be in the scope
33
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
34
+
35
+ // Unwatch the signal
36
+ unwatch();
37
+
38
+ await sleep(50);
39
+
40
+ // Signal should be garbage collected
41
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(0);
42
+ });
43
+
44
+ it('should not garbage collect signals with shouldGC returning false', async () => {
45
+ // Create a signal that should not be garbage collected
46
+ const persistentSignal = reactive(() => 'persist', {
47
+ shouldGC: () => false,
48
+ });
49
+
50
+ const w = watcher(() => persistentSignal());
51
+
52
+ // Watch the signal
53
+ const unwatch = w.addListener(() => {
54
+ persistentSignal();
55
+ });
56
+
57
+ await nextTick();
58
+
59
+ // Signal should be in the scope
60
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
61
+
62
+ // Unwatch the signal
63
+ unwatch();
64
+
65
+ await sleep(50);
66
+
67
+ // Signal should still be in the scope
68
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
69
+ });
70
+
71
+ it('should support conditional GC based on signal state', async () => {
72
+ // Create a signal with conditional GC
73
+ const shouldAllowGC = state(false);
74
+
75
+ const conditionalSignal = reactive(() => shouldAllowGC.get(), {
76
+ shouldGC: (signal, value) => {
77
+ // console.log('shouldGC', signal, value);
78
+ return value;
79
+ },
80
+ });
81
+
82
+ const w = watcher(() => conditionalSignal());
83
+
84
+ // Watch the signal
85
+ const unwatch = w.addListener(() => {
86
+ conditionalSignal();
87
+ });
88
+
89
+ await nextTick();
90
+
91
+ // Signal should be in the scope
92
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
93
+
94
+ // Unwatch the signal
95
+ unwatch();
96
+
97
+ await sleep(50);
98
+
99
+ // Signal should still be in the scope because shouldGC returns false
100
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
101
+
102
+ // Now allow GC
103
+ shouldAllowGC.set(true);
104
+
105
+ // Watch the signal
106
+ const unwatch2 = w.addListener(() => {
107
+ conditionalSignal();
108
+ });
109
+
110
+ await nextTick();
111
+
112
+ // Signal should be in GC candidates
113
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
114
+
115
+ unwatch2();
116
+
117
+ await sleep(50);
118
+
119
+ // Signal should be garbage collected
120
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(0);
121
+ });
122
+
123
+ it('should support manual garbage collection', async () => {
124
+ let signalObj: object;
125
+
126
+ // Create multiple signals
127
+ const signal1 = reactive(() => 'signal1');
128
+ const signal2 = reactive(() => 'signal2', {
129
+ shouldGC: signal => {
130
+ signalObj = signal;
131
+ return false;
132
+ },
133
+ });
134
+
135
+ const w1 = watcher(() => signal1());
136
+ const w2 = watcher(() => signal2());
137
+
138
+ // Watch both signals
139
+ const unwatch1 = w1.addListener(() => {
140
+ signal1();
141
+ });
142
+ const unwatch2 = w2.addListener(() => {
143
+ signal2();
144
+ });
145
+
146
+ await nextTick();
147
+
148
+ // Both signals should be in the scope
149
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(2);
150
+
151
+ // Unwatch both signals
152
+ unwatch1();
153
+ unwatch2();
154
+
155
+ await sleep(50);
156
+
157
+ // Only signal1 should be garbage collected
158
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
159
+
160
+ // The remaining signal should be signal2
161
+ const remainingSignal = Array.from(getSignalsMap(ROOT_SCOPE).values())[0];
162
+ expect(remainingSignal.get()).toBe('signal2');
163
+
164
+ forceGc(signalObj!);
165
+
166
+ await sleep(50);
167
+
168
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(0);
169
+ });
170
+
171
+ it('should not garbage collect signals that are still being watched', async () => {
172
+ // Create a signal
173
+ const watchedSignal = reactive(() => 'watched');
174
+
175
+ const w = watcher(() => watchedSignal());
176
+
177
+ // Watch the signal but don't unwatch
178
+ w.addListener(() => {
179
+ watchedSignal();
180
+ });
181
+
182
+ await nextTick();
183
+
184
+ // Signal should be in the scope
185
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
186
+
187
+ await sleep(50);
188
+
189
+ // Signal should still be in the scope because it's being watched
190
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
191
+ });
192
+
193
+ it('should handle context-scoped signals correctly', async () => {
194
+ // Create a context
195
+ const TestContext = createContext('test');
196
+
197
+ // Create signals in context
198
+ let contextSignal: any;
199
+
200
+ withContexts([[TestContext, 'value']], () => {
201
+ contextSignal = reactive(() => 'context-scoped');
202
+
203
+ const w = watcher(() => contextSignal());
204
+
205
+ // Watch and unwatch
206
+ const unwatch = w.addListener(() => {
207
+ contextSignal();
208
+ });
209
+
210
+ unwatch();
211
+ });
212
+
213
+ await nextTick();
214
+
215
+ // Get the context scope (this is a bit hacky for testing)
216
+ const contextScope = (ROOT_SCOPE as any).children.values().next().value;
217
+
218
+ await sleep(50);
219
+
220
+ // Signal should be garbage collected from the context scope
221
+ expect(getSignalsMap(contextScope).size).toBe(0);
222
+ });
223
+
224
+ it('should remove signal from GC candidates if watched again', async () => {
225
+ // Create a signal
226
+ const signal = reactive(() => 'rewatch');
227
+
228
+ const w = watcher(() => signal());
229
+
230
+ // Watch and unwatch
231
+ const unwatch = w.addListener(() => {
232
+ signal();
233
+ });
234
+
235
+ await nextTick();
236
+
237
+ // Signal should be in the scope
238
+ expect(getSignalsMap(ROOT_SCOPE).size).toBe(1);
239
+
240
+ unwatch();
241
+ await nextTick();
242
+
243
+ // Signal should be in GC candidates
244
+ expect(getGCCandidates(ROOT_SCOPE).size).toBe(1);
245
+
246
+ // Watch again
247
+ w.addListener(() => {
248
+ signal();
249
+ });
250
+
251
+ await nextTick();
252
+
253
+ // Signal should be removed from GC candidates
254
+ expect(getGCCandidates(ROOT_SCOPE).size).toBe(0);
255
+ });
256
+ });
@@ -9,7 +9,7 @@ import {
9
9
  } from '../../index.js';
10
10
  import { ReactiveTask, ReactiveValue, SignalOptionsWithInit, SignalSubscription } from '../../types.js';
11
11
  import { Context, ContextImpl, getCurrentScope, ROOT_SCOPE, SignalScope } from '../../internals/contexts.js';
12
- import { createDerivedSignal, DerivedSignal } from '../../internals/derived.js';
12
+ import { DerivedSignal } from '../../internals/derived.js';
13
13
  import { ReactivePromise } from '../../internals/async.js';
14
14
  import { hashValue } from '../../internals/utils/hash.js';
15
15
 
@@ -71,10 +71,10 @@ const WATCHERS = new WeakMap<Function, DerivedSignal<unknown, unknown[]>>();
71
71
 
72
72
  // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
73
73
  function getWatcherForHook(hook: Function) {
74
- let watcher = WATCHERS.get(hook);
74
+ let w = WATCHERS.get(hook);
75
75
 
76
- if (!watcher) {
77
- watcher = createDerivedSignal(
76
+ if (!w) {
77
+ w = watcher(
78
78
  () => {
79
79
  let result = hook();
80
80
 
@@ -84,18 +84,15 @@ function getWatcherForHook(hook: Function) {
84
84
 
85
85
  return result;
86
86
  },
87
- undefined,
88
- undefined,
89
- undefined,
90
87
  { desc: 'test' + TEST_ID++ },
91
- );
88
+ ) as DerivedSignal<unknown, unknown[]>;
92
89
 
93
- unsubs.push(watcher.addListener(() => {}));
90
+ unsubs.push(w.addListener(() => {}));
94
91
 
95
- WATCHERS.set(hook, watcher);
92
+ WATCHERS.set(hook, w);
96
93
  }
97
94
 
98
- return watcher;
95
+ return w;
99
96
  }
100
97
 
101
98
  function toHaveSignalValue(
package/src/config.ts CHANGED
@@ -14,7 +14,7 @@ interface SignalHooksConfig {
14
14
  runBatch: BatchFn;
15
15
  getFrameworkScope: () => SignalScope | undefined;
16
16
  useStateSignal: <T>(signal: StateSignal<T>) => T;
17
- useDerivedSignal: <T>(signal: DerivedSignal<T, unknown[]>) => ReactiveValue<T>;
17
+ useDerivedSignal: <T, Args extends unknown[]>(signal: DerivedSignal<T, Args>) => ReactiveValue<T>;
18
18
  }
19
19
 
20
20
  export let scheduleFlush: FlushFn = flushWatchers => {
@@ -28,9 +28,11 @@ export let runBatch: BatchFn = fn => fn();
28
28
  export let getFrameworkScope: () => SignalScope | undefined = () => undefined;
29
29
 
30
30
  let useFrameworkStateSignal: <T>(signal: StateSignal<T>) => T = signal => signal.get();
31
- let useFrameworkDerivedSignal: <T>(signal: DerivedSignal<T, unknown[]>) => ReactiveValue<T> = signal => signal.get();
31
+ let useFrameworkDerivedSignal: <T, Args extends unknown[]>(
32
+ signal: DerivedSignal<T, Args>,
33
+ ) => ReactiveValue<T> = signal => signal.get();
32
34
 
33
- export function useDerivedSignal<T>(signal: DerivedSignal<T, any[]>): ReactiveValue<T> {
35
+ export function useDerivedSignal<T, Args extends unknown[]>(signal: DerivedSignal<T, Args>): ReactiveValue<T> {
34
36
  if (CURRENT_CONSUMER !== undefined) {
35
37
  return signal.get();
36
38
  } else {
package/src/hooks.ts CHANGED
@@ -11,9 +11,10 @@ import {
11
11
  import { useDerivedSignal } from './config.js';
12
12
  import { getCurrentScope, SignalScope } from './internals/contexts.js';
13
13
  import { createStateSignal } from './internals/state.js';
14
- import { createDerivedSignal } from './internals/derived.js';
14
+ import { createDerivedSignal, DerivedSignalDefinition } from './internals/derived.js';
15
15
  import { ReactivePromise } from './internals/async.js';
16
16
  import { Tracer } from './trace.js';
17
+ import { equalsFrom } from './internals/utils/equals.js';
17
18
 
18
19
  export const state = createStateSignal;
19
20
 
@@ -29,9 +30,16 @@ export function reactive<T, Args extends unknown[]>(
29
30
  fn: (...args: Args) => T,
30
31
  opts?: Partial<SignalOptionsWithInit<T, Args>>,
31
32
  ): (...args: Args) => ReactiveValue<T> {
33
+ const def: DerivedSignalDefinition<T, Args> = {
34
+ compute: fn,
35
+ equals: equalsFrom(opts?.equals),
36
+ shouldGC: opts?.shouldGC,
37
+ isSubscription: false,
38
+ };
39
+
32
40
  return (...args) => {
33
41
  const scope = getCurrentScope();
34
- const signal = scope.get(fn as any, args, opts);
42
+ const signal = scope.get(def, args, opts);
35
43
 
36
44
  return useDerivedSignal(signal)!;
37
45
  };
@@ -64,5 +72,11 @@ export function watcher<T>(
64
72
  fn: () => T,
65
73
  opts?: SignalOptions<T, unknown[]> & { scope?: SignalScope; tracer?: Tracer },
66
74
  ): Signal<ReactiveValue<T>> {
67
- return createDerivedSignal(fn, undefined, undefined, opts?.scope, opts);
75
+ const def: DerivedSignalDefinition<T, unknown[]> = {
76
+ compute: fn,
77
+ equals: equalsFrom(opts?.equals),
78
+ isSubscription: false,
79
+ };
80
+
81
+ return createDerivedSignal(def, undefined, undefined, opts?.scope, opts);
68
82
  }
package/src/index.ts CHANGED
@@ -6,7 +6,14 @@ export { isReactivePromise, isReactiveTask, isReactiveSubscription } from './int
6
6
 
7
7
  export { callback } from './internals/get.js';
8
8
 
9
- export { createContext, useContext, withContexts, SignalScope, CONTEXT_KEY } from './internals/contexts.js';
9
+ export {
10
+ createContext,
11
+ useContext,
12
+ withContexts,
13
+ setRootContexts,
14
+ SignalScope,
15
+ CONTEXT_KEY,
16
+ } from './internals/contexts.js';
10
17
 
11
18
  export { setConfig } from './config.js';
12
19
 
@@ -8,7 +8,7 @@ import {
8
8
  SignalSubscribe,
9
9
  SignalSubscription,
10
10
  } from '../types.js';
11
- import { createDerivedSignal, DerivedSignal, SignalState } from './derived.js';
11
+ import { createDerivedSignal, DerivedSignal, DerivedSignalDefinition, SignalState } from './derived.js';
12
12
  import { CURRENT_CONSUMER, generatorResultToPromise, getSignal } from './get.js';
13
13
  import { dirtySignal, dirtySignalConsumers } from './dirty.js';
14
14
  import { scheduleAsyncPull, schedulePull, setResolved } from './scheduling.js';
@@ -17,7 +17,7 @@ import { getCurrentScope, ROOT_SCOPE, SignalScope, withScope } from './contexts.
17
17
  import { createStateSignal } from './state.js';
18
18
  import { useStateSignal } from '../config.js';
19
19
  import { isGeneratorResult, isPromise } from './utils/type-utils.js';
20
- import { equalsFrom } from './utils/equals.js';
20
+ import { DEFAULT_EQUALS, equalsFrom, FALSE_EQUALS } from './utils/equals.js';
21
21
 
22
22
  const enum AsyncFlags {
23
23
  // ======= Notifiers ========
@@ -72,10 +72,10 @@ export class ReactivePromise<T, Args extends unknown[] = unknown[]> implements B
72
72
  private _boundRun: ((...args: Args) => ReactivePromise<T, Args>) | undefined;
73
73
 
74
74
  static createPromise<T>(promise: Promise<T>, signal?: DerivedSignal<T, unknown[]>, initValue?: T | undefined) {
75
- const p = new ReactivePromise();
75
+ const p = new ReactivePromise<T>();
76
76
 
77
77
  p._signal = signal;
78
- p._equals = signal?.equals ?? ((a, b) => a === b);
78
+ p._equals = signal?.def.equals ?? DEFAULT_EQUALS;
79
79
 
80
80
  p._initFlags(AsyncFlags.Pending, initValue);
81
81
 
@@ -130,8 +130,8 @@ export class ReactivePromise<T, Args extends unknown[] = unknown[]> implements B
130
130
  },
131
131
  };
132
132
 
133
- p._signal = createDerivedSignal(
134
- () => {
133
+ const def: DerivedSignalDefinition<() => void, unknown[]> = {
134
+ compute: () => {
135
135
  if (active === false) {
136
136
  currentSub = subscribe(state);
137
137
  active = true;
@@ -144,11 +144,16 @@ export class ReactivePromise<T, Args extends unknown[] = unknown[]> implements B
144
144
 
145
145
  return unsubscribe;
146
146
  },
147
+ equals: DEFAULT_EQUALS,
148
+ isSubscription: true,
149
+ };
150
+
151
+ p._signal = createDerivedSignal<() => void, unknown[]>(
152
+ def,
147
153
  [],
148
154
  undefined,
149
155
  scope,
150
- opts as Omit<SignalOptionsWithInit<T, unknown[]>, 'equals' | 'initValue' | 'paramKey'>,
151
- true,
156
+ opts as Omit<SignalOptionsWithInit<T, unknown[]>, 'equals' | 'initValue' | 'paramKey' | 'shouldGC'>,
152
157
  );
153
158
 
154
159
  p._equals = equalsFrom(opts?.equals);
@@ -10,6 +10,9 @@ export function watchSignal(signal: DerivedSignal<any, any>): void {
10
10
  // If > 0, already watching, return
11
11
  if (watchCount > 0) return;
12
12
 
13
+ // If signal is being watched again, remove from GC candidates
14
+ signal.scope?.removeFromGc(signal);
15
+
13
16
  for (const dep of signal.deps.keys()) {
14
17
  watchSignal(dep);
15
18
  }
@@ -38,4 +41,9 @@ export function unwatchSignal(signal: DerivedSignal<any, any>, count = 1) {
38
41
  // teardown the subscription
39
42
  signal.value?.();
40
43
  }
44
+
45
+ // If watchCount is now zero, mark the signal for GC
46
+ if (newWatchCount === 0 && signal.scope) {
47
+ signal.scope.markForGc(signal);
48
+ }
41
49
  }
@@ -1,8 +1,9 @@
1
1
  import { getFrameworkScope } from '../config.js';
2
2
  import { SignalOptionsWithInit } from '../types.js';
3
- import { DerivedSignal, createDerivedSignal } from './derived.js';
3
+ import { DerivedSignal, DerivedSignalDefinition, createDerivedSignal } from './derived.js';
4
4
  import { CURRENT_CONSUMER } from './get.js';
5
5
  import { hashReactiveFn, hashValue } from './utils/hash.js';
6
+ import { scheduleGcSweep } from './scheduling.js';
6
7
 
7
8
  export const CONTEXT_KEY = Symbol('signalium:context');
8
9
 
@@ -46,7 +47,8 @@ export class SignalScope {
46
47
  private parentScope?: SignalScope = undefined;
47
48
  private contexts: Record<symbol, unknown>;
48
49
  private children = new Map<number, SignalScope>();
49
- private signals = new Map<number, DerivedSignal<any, any[]>>();
50
+ private signals = new Map<number, DerivedSignal<any, any>>();
51
+ private gcCandidates = new Set<DerivedSignal<any, any>>();
50
52
 
51
53
  getChild(contexts: [ContextImpl<unknown>, unknown][]) {
52
54
  const key = hashValue(contexts);
@@ -68,25 +70,58 @@ export class SignalScope {
68
70
  }
69
71
 
70
72
  get<T, Args extends unknown[]>(
71
- fn: (...args: Args) => T,
73
+ def: DerivedSignalDefinition<T, Args>,
72
74
  args: Args,
73
75
  opts?: Partial<SignalOptionsWithInit<T, Args>>,
74
76
  ): DerivedSignal<T, Args> {
75
77
  const paramKey = opts?.paramKey?.(...args);
76
- const key = hashReactiveFn(fn, paramKey ? [paramKey] : args);
78
+ const key = hashReactiveFn(def.compute, paramKey ? [paramKey] : args);
77
79
  let signal = this.signals.get(key) as DerivedSignal<T, Args> | undefined;
78
80
 
79
81
  if (signal === undefined) {
80
- signal = createDerivedSignal(fn, args, key, this, opts);
82
+ signal = createDerivedSignal(def, args, key, this, opts);
81
83
  this.signals.set(key, signal);
82
84
  }
83
85
 
84
86
  return signal;
85
87
  }
88
+
89
+ markForGc(signal: DerivedSignal<any, any>) {
90
+ if (!this.gcCandidates.has(signal)) {
91
+ this.gcCandidates.add(signal);
92
+ scheduleGcSweep(this);
93
+ }
94
+ }
95
+
96
+ removeFromGc(signal: DerivedSignal<any, any>) {
97
+ this.gcCandidates.delete(signal);
98
+ }
99
+
100
+ forceGc(signal: DerivedSignal<any, any>) {
101
+ this.signals.delete(signal.key!);
102
+ }
103
+
104
+ sweepGc() {
105
+ for (const signal of this.gcCandidates) {
106
+ if (signal.watchCount === 0) {
107
+ const { shouldGC } = signal.def;
108
+
109
+ if (!shouldGC || shouldGC(signal, signal.value, signal.args)) {
110
+ this.signals.delete(signal.key!);
111
+ }
112
+ }
113
+ }
114
+
115
+ this.gcCandidates = new Set();
116
+ }
86
117
  }
87
118
 
88
119
  export let ROOT_SCOPE = new SignalScope([]);
89
120
 
121
+ export function setRootContexts<C extends unknown[], U>(contexts: [...ContextPair<C>]): void {
122
+ ROOT_SCOPE = new SignalScope(contexts as [ContextImpl<unknown>, unknown][], ROOT_SCOPE);
123
+ }
124
+
90
125
  export const clearRootScope = () => {
91
126
  ROOT_SCOPE = new SignalScope([]);
92
127
  };
@@ -131,3 +166,8 @@ export const useContext = <T>(context: Context<T>): T => {
131
166
 
132
167
  return scope.getContext(context) ?? (context as unknown as ContextImpl<T>).defaultValue;
133
168
  };
169
+
170
+ export function forceGc(_signal: object) {
171
+ const signal = _signal as DerivedSignal<any, any>;
172
+ signal.scope?.forceGc(signal);
173
+ }
@@ -3,12 +3,11 @@ import { Tracer, TRACER, TracerMeta } from '../trace.js';
3
3
  import { ReactiveValue, Signal, SignalEquals, SignalListener, SignalOptionsWithInit } from '../types.js';
4
4
  import { getUnknownSignalFnName } from './utils/debug-name.js';
5
5
  import { SignalScope } from './contexts.js';
6
- import { checkAndRunListeners, getSignal } from './get.js';
7
- import { Edge, EdgeType, SignalEdge } from './edge.js';
6
+ import { getSignal } from './get.js';
7
+ import { Edge } from './edge.js';
8
8
  import { schedulePull, scheduleUnwatch } from './scheduling.js';
9
9
  import { hashValue } from './utils/hash.js';
10
10
  import { stringifyValue } from './utils/stringify.js';
11
- import { equalsFrom } from './utils/equals.js';
12
11
 
13
12
  /**
14
13
  * This file contains computed signal base types and struct definitions.
@@ -52,6 +51,17 @@ interface ListenerMeta {
52
51
  cachedBoundAdd: (listener: SignalListener) => () => void;
53
52
  }
54
53
 
54
+ /**
55
+ * Shared definition for derived signals to reduce memory usage.
56
+ * Contains configuration that's common across all instances of a reactive function.
57
+ */
58
+ export interface DerivedSignalDefinition<T, Args extends unknown[]> {
59
+ compute: (...args: Args) => T;
60
+ equals: SignalEquals<T>;
61
+ shouldGC?: (signal: object, value: T, args: Args) => boolean;
62
+ isSubscription: boolean;
63
+ }
64
+
55
65
  export class DerivedSignal<T, Args extends unknown[]> implements Signal<ReactiveValue<T>> {
56
66
  // Bitmask containing state in the first 2 bits and boolean properties in the remaining bits
57
67
  private flags: number;
@@ -62,7 +72,6 @@ export class DerivedSignal<T, Args extends unknown[]> implements Signal<Reactive
62
72
 
63
73
  ref: WeakRef<DerivedSignal<T, Args>> = new WeakRef(this);
64
74
 
65
- equals: SignalEquals<any>;
66
75
  dirtyHead: Edge | undefined = undefined;
67
76
 
68
77
  updatedCount: number = 0;
@@ -72,32 +81,33 @@ export class DerivedSignal<T, Args extends unknown[]> implements Signal<Reactive
72
81
 
73
82
  _listeners: ListenerMeta | null = null;
74
83
 
75
- compute: (...args: Args) => T;
84
+ key: SignalId | undefined;
76
85
  args: Args;
77
86
  value: ReactiveValue<T> | undefined;
78
87
 
79
88
  tracerMeta?: TracerMeta;
80
89
 
90
+ // Reference to the shared definition
91
+ def: DerivedSignalDefinition<T, Args>;
92
+
81
93
  constructor(
82
- isSubscription: boolean,
83
- compute: (...args: Args) => T,
94
+ definition: DerivedSignalDefinition<T, Args>,
84
95
  args: Args,
85
96
  key?: SignalId,
86
97
  scope?: SignalScope,
87
98
  opts?: Partial<SignalOptionsWithInit<T, Args>> & { tracer?: Tracer },
88
99
  ) {
89
- this.flags = (isSubscription ? SignalFlags.isSubscription : 0) | SignalState.Dirty;
100
+ this.flags = (definition.isSubscription ? SignalFlags.isSubscription : 0) | SignalState.Dirty;
90
101
  this.scope = scope;
91
- this.compute = compute;
102
+ this.key = key;
92
103
  this.args = args;
93
-
94
- this.equals = equalsFrom(opts?.equals);
104
+ this.def = definition;
95
105
  this.value = opts?.initValue as ReactiveValue<T>;
96
106
 
97
107
  if (TRACER) {
98
108
  this.tracerMeta = {
99
- id: opts?.id ?? key ?? hashValue([compute, ID++]),
100
- desc: opts?.desc ?? compute.name ?? getUnknownSignalFnName(compute),
109
+ id: opts?.id ?? key ?? hashValue([definition.compute, ID++]),
110
+ desc: opts?.desc ?? definition.compute.name ?? getUnknownSignalFnName(definition.compute),
101
111
  params: args.map(arg => stringifyValue(arg)).join(', '),
102
112
  tracer: opts?.tracer,
103
113
  };
@@ -197,12 +207,11 @@ export const isSubscription = (signal: unknown): boolean => {
197
207
  };
198
208
 
199
209
  export function createDerivedSignal<T, Args extends unknown[]>(
200
- compute: (...args: Args) => T,
210
+ def: DerivedSignalDefinition<T, Args>,
201
211
  args: Args = [] as any,
202
212
  key?: SignalId,
203
213
  scope?: SignalScope,
204
214
  opts?: Partial<SignalOptionsWithInit<T, Args>> & { tracer?: Tracer },
205
- isSubscription: boolean = false,
206
215
  ): DerivedSignal<T, Args> {
207
- return new DerivedSignal(isSubscription, compute, args, key, scope, opts);
216
+ return new DerivedSignal(def, args, key, scope, opts);
208
217
  }
@@ -128,7 +128,7 @@ export function runSignal(signal: DerivedSignal<any, any[]>) {
128
128
 
129
129
  const initialized = updatedCount !== 0;
130
130
  const prevValue = signal.value;
131
- let nextValue = signal.compute(...signal.args);
131
+ let nextValue = signal.def.compute(...signal.args);
132
132
  let valueIsPromise = false;
133
133
 
134
134
  if (nextValue !== null && typeof nextValue === 'object') {
@@ -177,7 +177,7 @@ export function runSignal(signal: DerivedSignal<any, any[]>) {
177
177
  signal.value = ReactivePromise.createPromise(nextValue, signal, initValue);
178
178
  signal.updatedCount = updatedCount + 1;
179
179
  }
180
- } else if (!initialized || !signal.equals(prevValue!, nextValue)) {
180
+ } else if (!initialized || !signal.def.equals(prevValue!, nextValue)) {
181
181
  signal.value = nextValue;
182
182
  signal.updatedCount = updatedCount + 1;
183
183
  }