@preact/signals-react 1.3.7 → 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.
@@ -42,41 +42,99 @@ interface Effect {
42
42
  _dispose(): void;
43
43
  }
44
44
 
45
+ /**
46
+ * Use this flag to represent a bare `useSignals` call that doesn't manually
47
+ * close its effect store and relies on auto-closing when the next useSignals is
48
+ * called or after a microtask
49
+ */
50
+ const UNMANAGED = 0;
51
+ /**
52
+ * Use this flag to represent a `useSignals` call that is manually closed by a
53
+ * try/finally block in a component's render method. This is the default usage
54
+ * that the react-transform plugin uses.
55
+ */
56
+ const MANAGED_COMPONENT = 1;
57
+ /**
58
+ * Use this flag to represent a `useSignals` call that is manually closed by a
59
+ * try/finally block in a hook body. This is the default usage that the
60
+ * react-transform plugin uses.
61
+ */
62
+ const MANAGED_HOOK = 2;
63
+
64
+ /**
65
+ * An enum defining how this store is used. See the documentation for each enum
66
+ * member for more details.
67
+ * @see {@link UNMANAGED}
68
+ * @see {@link MANAGED_COMPONENT}
69
+ * @see {@link MANAGED_HOOK}
70
+ */
71
+ type EffectStoreUsage =
72
+ | typeof UNMANAGED
73
+ | typeof MANAGED_COMPONENT
74
+ | typeof MANAGED_HOOK;
75
+
45
76
  export interface EffectStore {
46
- effect: Effect;
77
+ /**
78
+ * An enum defining how this hook is used and whether it is invoked in a
79
+ * component's body or hook body. See the comment on `EffectStoreUsage` for
80
+ * more details.
81
+ */
82
+ readonly _usage: EffectStoreUsage;
83
+ readonly effect: Effect;
47
84
  subscribe(onStoreChange: () => void): () => void;
48
85
  getSnapshot(): number;
86
+ /** startEffect - begin tracking signals used in this component */
87
+ _start(): void;
49
88
  /** finishEffect - stop tracking the signals used in this component */
50
89
  f(): void;
51
90
  [symDispose](): void;
52
91
  }
53
92
 
54
- let finishUpdate: (() => void) | undefined;
93
+ let currentStore: EffectStore | undefined;
55
94
 
56
- function setCurrentStore(store?: EffectStore) {
57
- // end tracking for the current update:
58
- if (finishUpdate) finishUpdate();
59
- // start tracking the new update:
60
- finishUpdate = store && store.effect._start();
95
+ function startComponentEffect(
96
+ prevStore: EffectStore | undefined,
97
+ nextStore: EffectStore
98
+ ) {
99
+ const endEffect = nextStore.effect._start();
100
+ currentStore = nextStore;
101
+
102
+ return finishComponentEffect.bind(nextStore, prevStore, endEffect);
61
103
  }
62
104
 
63
- const clearCurrentStore = () => setCurrentStore();
105
+ function finishComponentEffect(
106
+ this: EffectStore,
107
+ prevStore: EffectStore | undefined,
108
+ endEffect: () => void
109
+ ) {
110
+ endEffect();
111
+ currentStore = prevStore;
112
+ }
64
113
 
65
114
  /**
66
- * A redux-like store whose store value is a positive 32bit integer (a 'version').
115
+ * A redux-like store whose store value is a positive 32bit integer (a
116
+ * 'version').
67
117
  *
68
118
  * React subscribes to this store and gets a snapshot of the current 'version',
69
- * whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
119
+ * whenever the 'version' changes, we tell React it's time to update the
120
+ * component (call 'onStoreChange').
70
121
  *
71
- * How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
72
- * we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
122
+ * How we achieve this is by creating a binding with an 'effect', when the
123
+ * `effect._callback' is called, we update our store version and tell React to
124
+ * re-render the component ([1] We don't really care when/how React does it).
73
125
  *
74
126
  * [1]
75
127
  * @see https://react.dev/reference/react/useSyncExternalStore
76
- * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
128
+ * @see
129
+ * https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
130
+ *
131
+ * @param _usage An enum defining how this hook is used and whether it is
132
+ * invoked in a component's body or hook body. See the comment on
133
+ * `EffectStoreUsage` for more details.
77
134
  */
78
- function createEffectStore(): EffectStore {
135
+ function createEffectStore(_usage: EffectStoreUsage): EffectStore {
79
136
  let effectInstance!: Effect;
137
+ let endEffect: (() => void) | undefined;
80
138
  let version = 0;
81
139
  let onChangeNotifyReact: (() => void) | undefined;
82
140
 
@@ -89,6 +147,7 @@ function createEffectStore(): EffectStore {
89
147
  };
90
148
 
91
149
  return {
150
+ _usage,
92
151
  effect: effectInstance,
93
152
  subscribe(onStoreChange) {
94
153
  onChangeNotifyReact = onStoreChange;
@@ -112,17 +171,115 @@ function createEffectStore(): EffectStore {
112
171
  getSnapshot() {
113
172
  return version;
114
173
  },
174
+ _start() {
175
+ // In general, we want to support two kinds of usages of useSignals:
176
+ //
177
+ // A) Managed: calling useSignals in a component or hook body wrapped in a
178
+ // try/finally (like what the react-transform plugin does)
179
+ //
180
+ // B) Unmanaged: Calling useSignals directly without wrapping in a
181
+ // try/finally
182
+ //
183
+ // For managed, we finish the effect in the finally block of the component
184
+ // or hook body. For unmanaged, we finish the effect in the next
185
+ // useSignals call or after a microtask.
186
+ //
187
+ // There are different tradeoffs which each approach. With managed, using
188
+ // a try/finally ensures that only signals used in the component or hook
189
+ // body are tracked. However, signals accessed in render props are missed
190
+ // because the render prop is invoked in another component that may or may
191
+ // not realize it is rendering signals accessed in the render prop it is
192
+ // given.
193
+ //
194
+ // The other approach is "unmanaged": to call useSignals directly without
195
+ // wrapping in a try/finally. This approach is easier to manually write in
196
+ // situations where a build step isn't available but does open up the
197
+ // possibility of catching signals accessed in other code before the
198
+ // effect is closed (e.g. in a layout effect). Most situations where this
199
+ // could happen are generally consider bad patterns or bugs. For example,
200
+ // using a signal in a component and not having a call to `useSignals`
201
+ // would be an bug. Or using a signal in `useLayoutEffect` is generally
202
+ // not recommended since that layout effect won't update when the signals'
203
+ // value change.
204
+ //
205
+ // To support both approaches, we need to track how each invocation of
206
+ // useSignals is used, so we can properly transition between different
207
+ // kinds of usages.
208
+ //
209
+ // The following table shows the different scenarios and how we should
210
+ // handle them.
211
+ //
212
+ // Key:
213
+ // 0 = UNMANAGED
214
+ // 1 = MANAGED_COMPONENT
215
+ // 2 = MANAGED_HOOK
216
+ //
217
+ // Pattern:
218
+ // prev store usage -> this store usage: action to take
219
+ //
220
+ // - 0 -> 0: finish previous effect (unknown to unknown)
221
+ //
222
+ // We don't know how the previous effect was used, so we need to finish
223
+ // it before starting the next effect.
224
+ //
225
+ // - 0 -> 1: finish previous effect
226
+ //
227
+ // Assume previous invocation was another component or hook from another
228
+ // component. Nested component renders (renderToStaticMarkup within a
229
+ // component's render) won't be supported with bare useSignals calls.
230
+ //
231
+ // - 0 -> 2: capture & restore
232
+ //
233
+ // Previous invocation could be a component or a hook. Either way,
234
+ // restore it after our invocation so that it can continue to capture
235
+ // any signals after we exit.
236
+ //
237
+ // - 1 -> 0: Do nothing. Signals already captured by current effect store
238
+ // - 1 -> 1: capture & restore (e.g. component calls renderToStaticMarkup)
239
+ // - 1 -> 2: capture & restore (e.g. hook)
240
+ //
241
+ // - 2 -> 0: Do nothing. Signals already captured by current effect store
242
+ // - 2 -> 1: capture & restore (e.g. hook calls renderToStaticMarkup)
243
+ // - 2 -> 2: capture & restore (e.g. nested hook calls)
244
+
245
+ if (currentStore == undefined) {
246
+ endEffect = startComponentEffect(undefined, this);
247
+ return;
248
+ }
249
+
250
+ const prevUsage = currentStore._usage;
251
+ const thisUsage = this._usage;
252
+
253
+ if (
254
+ (prevUsage == UNMANAGED && thisUsage == UNMANAGED) || // 0 -> 0
255
+ (prevUsage == UNMANAGED && thisUsage == MANAGED_COMPONENT) // 0 -> 1
256
+ ) {
257
+ // finish previous effect
258
+ currentStore.f();
259
+ endEffect = startComponentEffect(undefined, this);
260
+ } else if (
261
+ (prevUsage == MANAGED_COMPONENT && thisUsage == UNMANAGED) || // 1 -> 0
262
+ (prevUsage == MANAGED_HOOK && thisUsage == UNMANAGED) // 2 -> 0
263
+ ) {
264
+ // Do nothing since it'll be captured by current effect store
265
+ } else {
266
+ // nested scenarios, so capture and restore the previous effect store
267
+ endEffect = startComponentEffect(currentStore, this);
268
+ }
269
+ },
115
270
  f() {
116
- clearCurrentStore();
271
+ endEffect?.();
272
+ endEffect = undefined;
117
273
  },
118
274
  [symDispose]() {
119
- clearCurrentStore();
275
+ this.f();
120
276
  },
121
277
  };
122
278
  }
123
279
 
124
280
  function createEmptyEffectStore(): EffectStore {
125
281
  return {
282
+ _usage: UNMANAGED,
126
283
  effect: {
127
284
  _sources: undefined,
128
285
  _callback() {},
@@ -137,6 +294,7 @@ function createEmptyEffectStore(): EffectStore {
137
294
  getSnapshot() {
138
295
  return 0;
139
296
  },
297
+ _start() {},
140
298
  f() {},
141
299
  [symDispose]() {},
142
300
  };
@@ -144,30 +302,35 @@ function createEmptyEffectStore(): EffectStore {
144
302
 
145
303
  const emptyEffectStore = createEmptyEffectStore();
146
304
 
147
- let finalCleanup: Promise<void> | undefined;
148
305
  const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve());
149
306
 
150
- /**
151
- * Custom hook to create the effect to track signals used during render and
152
- * subscribe to changes to rerender the component when the signals change.
153
- */
154
- export function _useSignalsImplementation(): EffectStore {
155
- clearCurrentStore();
307
+ let finalCleanup: Promise<void> | undefined;
308
+ export function ensureFinalCleanup() {
156
309
  if (!finalCleanup) {
157
310
  finalCleanup = _queueMicroTask(() => {
158
311
  finalCleanup = undefined;
159
- clearCurrentStore();
312
+ currentStore?.f();
160
313
  });
161
314
  }
315
+ }
316
+
317
+ /**
318
+ * Custom hook to create the effect to track signals used during render and
319
+ * subscribe to changes to rerender the component when the signals change.
320
+ */
321
+ export function _useSignalsImplementation(
322
+ _usage: EffectStoreUsage = UNMANAGED
323
+ ): EffectStore {
324
+ ensureFinalCleanup();
162
325
 
163
326
  const storeRef = useRef<EffectStore>();
164
327
  if (storeRef.current == null) {
165
- storeRef.current = createEffectStore();
328
+ storeRef.current = createEffectStore(_usage);
166
329
  }
167
330
 
168
331
  const store = storeRef.current;
169
332
  useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
170
- setCurrentStore(store);
333
+ store._start();
171
334
 
172
335
  return store;
173
336
  }
@@ -176,7 +339,7 @@ export function _useSignalsImplementation(): EffectStore {
176
339
  * A wrapper component that renders a Signal's value directly as a Text node or JSX.
177
340
  */
178
341
  function SignalValue({ data }: { data: Signal }) {
179
- const store = useSignals();
342
+ const store = _useSignalsImplementation(1);
180
343
  try {
181
344
  return data.value;
182
345
  } finally {
@@ -197,9 +360,9 @@ Object.defineProperties(Signal.prototype, {
197
360
  ref: { configurable: true, value: null },
198
361
  });
199
362
 
200
- export function useSignals(): EffectStore {
363
+ export function useSignals(usage?: EffectStoreUsage): EffectStore {
201
364
  if (isAutoSignalTrackingInstalled) return emptyEffectStore;
202
- return _useSignalsImplementation();
365
+ return _useSignalsImplementation(usage);
203
366
  }
204
367
 
205
368
  export function useSignal<T>(value: T): Signal<T> {
package/src/index.ts CHANGED
@@ -1,3 +1,11 @@
1
+ // !!!!!!!!!!!!!!!!!!!!
2
+ //
3
+ // Imports to other packages (e.g. `react` or `@preact/signals-core`) or
4
+ // subpackages (e.g. `@preact/signals-react/runtime`) in this file should be
5
+ // listed as "external" in cmdline arguments passed to microbundle in the root
6
+ // package.json script for this package so their contents aren't bundled into
7
+ // the final source file.
8
+
1
9
  import {
2
10
  signal,
3
11
  computed,
@@ -12,8 +20,7 @@ import {
12
20
  useSignal,
13
21
  useComputed,
14
22
  useSignalEffect,
15
- installAutoSignalTracking,
16
- } from "../runtime/src/index"; // TODO: This duplicates runtime code between main and sub runtime packages
23
+ } from "@preact/signals-react/runtime";
17
24
 
18
25
  export {
19
26
  signal,
@@ -33,5 +40,3 @@ declare module "@preact/signals-core" {
33
40
  // eslint-disable-next-line @typescript-eslint/no-empty-interface
34
41
  interface Signal extends ReactElement {}
35
42
  }
36
-
37
- installAutoSignalTracking();