@preact/signals-react 1.3.6 → 1.3.8

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.
@@ -1,11 +1,19 @@
1
- import { signal, computed, effect, Signal } from "@preact/signals-core";
1
+ import {
2
+ signal,
3
+ computed,
4
+ effect,
5
+ Signal,
6
+ ReadonlySignal,
7
+ } from "@preact/signals-core";
2
8
  import { useRef, useMemo, useEffect } from "react";
3
9
  import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
10
+ import { isAutoSignalTrackingInstalled } from "./auto";
4
11
 
5
12
  export { installAutoSignalTracking } from "./auto";
6
13
 
7
14
  const Empty = [] as const;
8
15
  const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
16
+ const noop = () => {};
9
17
 
10
18
  export function wrapJsx<T>(jsx: T): T {
11
19
  if (typeof jsx !== "function") return jsx;
@@ -24,7 +32,8 @@ export function wrapJsx<T>(jsx: T): T {
24
32
  } as any as T;
25
33
  }
26
34
 
27
- const symDispose: unique symbol = (Symbol as any).dispose || Symbol.for("Symbol.dispose");
35
+ const symDispose: unique symbol =
36
+ (Symbol as any).dispose || Symbol.for("Symbol.dispose");
28
37
 
29
38
  interface Effect {
30
39
  _sources: object | undefined;
@@ -33,41 +42,99 @@ interface Effect {
33
42
  _dispose(): void;
34
43
  }
35
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
+
36
76
  export interface EffectStore {
37
- 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;
38
84
  subscribe(onStoreChange: () => void): () => void;
39
85
  getSnapshot(): number;
86
+ /** startEffect - begin tracking signals used in this component */
87
+ _start(): void;
40
88
  /** finishEffect - stop tracking the signals used in this component */
41
89
  f(): void;
42
90
  [symDispose](): void;
43
91
  }
44
92
 
45
- let finishUpdate: (() => void) | undefined;
93
+ let currentStore: EffectStore | undefined;
94
+
95
+ function startComponentEffect(
96
+ prevStore: EffectStore | undefined,
97
+ nextStore: EffectStore
98
+ ) {
99
+ const endEffect = nextStore.effect._start();
100
+ currentStore = nextStore;
46
101
 
47
- function setCurrentStore(store?: EffectStore) {
48
- // end tracking for the current update:
49
- if (finishUpdate) finishUpdate();
50
- // start tracking the new update:
51
- finishUpdate = store && store.effect._start();
102
+ return finishComponentEffect.bind(nextStore, prevStore, endEffect);
52
103
  }
53
104
 
54
- const clearCurrentStore = () => setCurrentStore();
105
+ function finishComponentEffect(
106
+ this: EffectStore,
107
+ prevStore: EffectStore | undefined,
108
+ endEffect: () => void
109
+ ) {
110
+ endEffect();
111
+ currentStore = prevStore;
112
+ }
55
113
 
56
114
  /**
57
- * 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').
58
117
  *
59
118
  * React subscribes to this store and gets a snapshot of the current 'version',
60
- * 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').
61
121
  *
62
- * How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
63
- * 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).
64
125
  *
65
126
  * [1]
66
127
  * @see https://react.dev/reference/react/useSyncExternalStore
67
- * @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.
68
134
  */
69
- function createEffectStore(): EffectStore {
135
+ function createEffectStore(_usage: EffectStoreUsage): EffectStore {
70
136
  let effectInstance!: Effect;
137
+ let endEffect: (() => void) | undefined;
71
138
  let version = 0;
72
139
  let onChangeNotifyReact: (() => void) | undefined;
73
140
 
@@ -80,6 +147,7 @@ function createEffectStore(): EffectStore {
80
147
  };
81
148
 
82
149
  return {
150
+ _usage,
83
151
  effect: effectInstance,
84
152
  subscribe(onStoreChange) {
85
153
  onChangeNotifyReact = onStoreChange;
@@ -103,39 +171,166 @@ function createEffectStore(): EffectStore {
103
171
  getSnapshot() {
104
172
  return version;
105
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
+ },
106
270
  f() {
107
- clearCurrentStore();
271
+ endEffect?.();
272
+ endEffect = undefined;
108
273
  },
109
274
  [symDispose]() {
110
- clearCurrentStore();
111
- }
275
+ this.f();
276
+ },
112
277
  };
113
278
  }
114
279
 
115
- let finalCleanup: Promise<void> | undefined;
280
+ function createEmptyEffectStore(): EffectStore {
281
+ return {
282
+ _usage: UNMANAGED,
283
+ effect: {
284
+ _sources: undefined,
285
+ _callback() {},
286
+ _start() {
287
+ return noop;
288
+ },
289
+ _dispose() {},
290
+ },
291
+ subscribe() {
292
+ return noop;
293
+ },
294
+ getSnapshot() {
295
+ return 0;
296
+ },
297
+ _start() {},
298
+ f() {},
299
+ [symDispose]() {},
300
+ };
301
+ }
302
+
303
+ const emptyEffectStore = createEmptyEffectStore();
304
+
116
305
  const _queueMicroTask = Promise.prototype.then.bind(Promise.resolve());
117
306
 
118
- /**
119
- * Custom hook to create the effect to track signals used during render and
120
- * subscribe to changes to rerender the component when the signals change.
121
- */
122
- export function useSignals(): EffectStore {
123
- clearCurrentStore();
307
+ let finalCleanup: Promise<void> | undefined;
308
+ export function ensureFinalCleanup() {
124
309
  if (!finalCleanup) {
125
310
  finalCleanup = _queueMicroTask(() => {
126
311
  finalCleanup = undefined;
127
- clearCurrentStore();
312
+ currentStore?.f();
128
313
  });
129
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();
130
325
 
131
326
  const storeRef = useRef<EffectStore>();
132
327
  if (storeRef.current == null) {
133
- storeRef.current = createEffectStore();
328
+ storeRef.current = createEffectStore(_usage);
134
329
  }
135
330
 
136
331
  const store = storeRef.current;
137
332
  useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
138
- setCurrentStore(store);
333
+ store._start();
139
334
 
140
335
  return store;
141
336
  }
@@ -144,7 +339,12 @@ export function useSignals(): EffectStore {
144
339
  * A wrapper component that renders a Signal's value directly as a Text node or JSX.
145
340
  */
146
341
  function SignalValue({ data }: { data: Signal }) {
147
- return data.value;
342
+ const store = _useSignalsImplementation(1);
343
+ try {
344
+ return data.value;
345
+ } finally {
346
+ store.f();
347
+ }
148
348
  }
149
349
 
150
350
  // Decorate Signals so React renders them as <SignalValue> components.
@@ -160,17 +360,22 @@ Object.defineProperties(Signal.prototype, {
160
360
  ref: { configurable: true, value: null },
161
361
  });
162
362
 
163
- export function useSignal<T>(value: T) {
363
+ export function useSignals(usage?: EffectStoreUsage): EffectStore {
364
+ if (isAutoSignalTrackingInstalled) return emptyEffectStore;
365
+ return _useSignalsImplementation(usage);
366
+ }
367
+
368
+ export function useSignal<T>(value: T): Signal<T> {
164
369
  return useMemo(() => signal<T>(value), Empty);
165
370
  }
166
371
 
167
- export function useComputed<T>(compute: () => T) {
372
+ export function useComputed<T>(compute: () => T): ReadonlySignal<T> {
168
373
  const $compute = useRef(compute);
169
374
  $compute.current = compute;
170
375
  return useMemo(() => computed<T>(() => $compute.current()), Empty);
171
376
  }
172
377
 
173
- export function useSignalEffect(cb: () => void | (() => void)) {
378
+ export function useSignalEffect(cb: () => void | (() => void)): void {
174
379
  const callback = useRef(cb);
175
380
  callback.current = cb;
176
381
 
package/src/index.ts CHANGED
@@ -8,8 +8,12 @@ import {
8
8
  untracked,
9
9
  } from "@preact/signals-core";
10
10
  import type { ReactElement } from "react";
11
- import { useSignal, useComputed, useSignalEffect } from "../runtime";
12
- import { installAutoSignalTracking } from "../runtime/src/auto";
11
+ import {
12
+ useSignal,
13
+ useComputed,
14
+ useSignalEffect,
15
+ installAutoSignalTracking,
16
+ } from "@preact/signals-react/runtime"; // TODO: This duplicates runtime code between main and sub runtime packages
13
17
 
14
18
  export {
15
19
  signal,