@preact/signals-react 1.2.1 → 1.3.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/src/index.ts CHANGED
@@ -2,13 +2,16 @@ import {
2
2
  useRef,
3
3
  useMemo,
4
4
  useEffect,
5
- Component,
6
- type FunctionComponent,
5
+ // @ts-ignore-next-line
6
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
7
+ __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED as ReactInternals,
8
+ type ReactElement,
9
+ type useCallback,
10
+ type useReducer,
7
11
  } from "react";
8
12
  import React from "react";
9
13
  import jsxRuntime from "react/jsx-runtime";
10
14
  import jsxRuntimeDev from "react/jsx-dev-runtime";
11
- import { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
12
15
  import {
13
16
  signal,
14
17
  computed,
@@ -17,84 +20,34 @@ import {
17
20
  Signal,
18
21
  type ReadonlySignal,
19
22
  } from "@preact/signals-core";
23
+ import { useSyncExternalStore } from "use-sync-external-store/shim/index";
20
24
  import type { Effect, JsxRuntimeModule } from "./internal";
21
25
 
22
26
  export { signal, computed, batch, effect, Signal, type ReadonlySignal };
23
27
 
24
28
  const Empty = [] as const;
25
29
  const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
26
- const ReactMemoType = Symbol.for("react.memo"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L30
27
- const ProxyInstance = new Map<FunctionComponent<any>, FunctionComponent<any>>();
28
- const SupportsProxy = typeof Proxy === "function";
29
-
30
- const ProxyHandlers = {
31
- /**
32
- * This is a function call trap for functional components.
33
- * When this is called, we know it means React did run 'Component()',
34
- * that means we can use any hooks here to setup our effect and store.
35
- *
36
- * With the native Proxy, all other calls such as access/setting to/of properties will
37
- * be forwarded to the target Component, so we don't need to copy the Component's
38
- * own or inherited properties.
39
- *
40
- * @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
41
- */
42
- apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
43
- const store = useMemo(createEffectStore, Empty);
44
-
45
- useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
46
-
47
- const stop = store.updater._start();
48
-
49
- try {
50
- const children = Component.apply(thisArg, argumentsList);
51
- return children;
52
- } catch (e) {
53
- // Re-throwing promises that'll be handled by suspense
54
- // or an actual error.
55
- throw e;
56
- } finally {
57
- // Stop effects in either case before return or throw,
58
- // Otherwise the effect will leak.
59
- stop();
60
- }
61
- },
62
- };
63
30
 
64
- function ProxyFunctionalComponent(Component: FunctionComponent<any>) {
65
- return ProxyInstance.get(Component) || WrapWithProxy(Component);
31
+ interface ReactDispatcher {
32
+ useRef: typeof useRef;
33
+ useCallback: typeof useCallback;
34
+ useReducer: typeof useReducer;
35
+ useSyncExternalStore: typeof useSyncExternalStore;
66
36
  }
67
- function WrapWithProxy(Component: FunctionComponent<any>) {
68
- if (SupportsProxy) {
69
- const ProxyComponent = new Proxy(Component, ProxyHandlers);
70
37
 
71
- ProxyInstance.set(Component, ProxyComponent);
72
- ProxyInstance.set(ProxyComponent, ProxyComponent);
38
+ let finishUpdate: (() => void) | undefined;
73
39
 
74
- return ProxyComponent;
75
- }
76
-
77
- /**
78
- * Emulate a Proxy if environment doesn't support it.
79
- *
80
- * @TODO - unlike Proxy, it's not possible to access the type/Component's
81
- * static properties this way. Not sure if we want to copy all statics here.
82
- * Omitting this for now.
83
- *
84
- * @example - works with Proxy, doesn't with wrapped function.
85
- * ```
86
- * const el = <SomeFunctionalComponent />
87
- * el.type.someOwnOrInheritedProperty;
88
- * el.type.defaultProps;
89
- * ```
90
- */
91
- const WrappedComponent = function () {
92
- return ProxyHandlers.apply(Component, undefined, arguments);
93
- };
94
- ProxyInstance.set(Component, WrappedComponent);
95
- ProxyInstance.set(WrappedComponent, WrappedComponent);
40
+ function setCurrentUpdater(updater?: Effect) {
41
+ // end tracking for the current update:
42
+ if (finishUpdate) finishUpdate();
43
+ // start tracking the new update:
44
+ finishUpdate = updater && updater._start();
45
+ }
96
46
 
97
- return WrappedComponent;
47
+ interface EffectStore {
48
+ updater: Effect;
49
+ subscribe(onStoreChange: () => void): () => void;
50
+ getSnapshot(): number;
98
51
  }
99
52
 
100
53
  /**
@@ -110,7 +63,7 @@ function WrapWithProxy(Component: FunctionComponent<any>) {
110
63
  * @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
111
64
  * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
112
65
  */
113
- function createEffectStore() {
66
+ function createEffectStore(): EffectStore {
114
67
  let updater!: Effect;
115
68
  let version = 0;
116
69
  let onChangeNotifyReact: (() => void) | undefined;
@@ -125,7 +78,7 @@ function createEffectStore() {
125
78
 
126
79
  return {
127
80
  updater,
128
- subscribe(onStoreChange: () => void) {
81
+ subscribe(onStoreChange) {
129
82
  onChangeNotifyReact = onStoreChange;
130
83
 
131
84
  return function () {
@@ -150,19 +103,144 @@ function createEffectStore() {
150
103
  };
151
104
  }
152
105
 
153
- function WrapJsx<T>(jsx: T): T {
154
- if (typeof jsx !== "function") return jsx;
106
+ /**
107
+ * Custom hook to create the effect to track signals used during render and
108
+ * subscribe to changes to rerender the component when the signals change
109
+ */
110
+ function usePreactSignalStore(nextDispatcher: ReactDispatcher): EffectStore {
111
+ const storeRef = nextDispatcher.useRef<EffectStore>();
112
+ if (storeRef.current == null) {
113
+ storeRef.current = createEffectStore();
114
+ }
155
115
 
156
- return function (type: any, props: any, ...rest: any[]) {
157
- if (typeof type === "function" && !(type instanceof Component)) {
158
- return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest);
116
+ const store = storeRef.current;
117
+ useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
118
+
119
+ return store;
120
+ }
121
+
122
+ // To track when we are entering and exiting a component render (i.e. before and
123
+ // after React renders a component), we track how the dispatcher changes.
124
+ // Outside of a component rendering, the dispatcher is set to an instance that
125
+ // errors or warns when any hooks are called. This behavior is prevents hooks
126
+ // from being used outside of components. Right before React renders a
127
+ // component, the dispatcher is set to a valid one. Right after React finishes
128
+ // rendering a component, the dispatcher is set to an erroring one again. This
129
+ // erroring dispatcher is called the `ContextOnlyDispatcher` in React's source.
130
+ //
131
+ // So, we watch the getter and setter on `ReactCurrentDispatcher.current` to
132
+ // monitor the changes to the current ReactDispatcher. When the dispatcher
133
+ // changes from the ContextOnlyDispatcher to a valid dispatcher, we assume we
134
+ // are entering a component render. At this point, we setup our
135
+ // auto-subscriptions for any signals used in the component. We do this by
136
+ // creating an effect and manually starting the effect. We use
137
+ // `useSyncExternalStore` to trigger rerenders on the component when any signals
138
+ // it uses changes.
139
+ //
140
+ // When the dispatcher changes from a valid dispatcher back to the
141
+ // ContextOnlyDispatcher, we assume we are exiting a component render. At this
142
+ // point we stop the effect.
143
+ //
144
+ // Some edge cases to be aware of:
145
+ // - In development, useReducer, useState, and useMemo changes the dispatcher to
146
+ // a different erroring dispatcher before invoking the reducer and resets it
147
+ // right after.
148
+ //
149
+ // The useSyncExternalStore shim will use some of these hooks when we invoke
150
+ // it while entering a component render. We need to prevent this dispatcher
151
+ // change caused by these hooks from re-triggering our entering logic (it
152
+ // would cause an infinite loop if we did not). We do this by using a lock to
153
+ // prevent the setter from running while we are in the setter.
154
+ //
155
+ // When a Component's function body invokes useReducer, useState, or useMemo,
156
+ // this change in dispatcher should not signal that we are exiting a component
157
+ // render. We ignore this change by detecting these dispatchers as different
158
+ // from ContextOnlyDispatcher and other valid dispatchers.
159
+ //
160
+ // - The `use` hook will change the dispatcher to from a valid update dispatcher
161
+ // to a valid mount dispatcher in some cases. Similarly to useReducer
162
+ // mentioned above, we should not signal that we are exiting a component
163
+ // during this change. Because these other valid dispatchers do not pass the
164
+ // ContextOnlyDispatcher check, they do not affect our logic.
165
+ let lock = false;
166
+ let currentDispatcher: ReactDispatcher | null = null;
167
+ Object.defineProperty(ReactInternals.ReactCurrentDispatcher, "current", {
168
+ get() {
169
+ return currentDispatcher;
170
+ },
171
+ set(nextDispatcher: ReactDispatcher) {
172
+ if (lock) {
173
+ currentDispatcher = nextDispatcher;
174
+ return;
159
175
  }
160
176
 
161
- if (type && typeof type === "object" && type.$$typeof === ReactMemoType) {
162
- type.type = ProxyFunctionalComponent(type.type);
163
- return jsx.call(jsx, type, props, ...rest);
177
+ const currentDispatcherType = getDispatcherType(currentDispatcher);
178
+ const nextDispatcherType = getDispatcherType(nextDispatcher);
179
+
180
+ // We are entering a component render if the current dispatcher is the
181
+ // ContextOnlyDispatcher and the next dispatcher is a valid dispatcher.
182
+ const isEnteringComponentRender =
183
+ currentDispatcherType === ContextOnlyDispatcherType &&
184
+ nextDispatcherType === ValidDispatcherType;
185
+
186
+ // We are exiting a component render if the current dispatcher is a valid
187
+ // dispatcher and the next dispatcher is the ContextOnlyDispatcher.
188
+ const isExitingComponentRender =
189
+ currentDispatcherType === ValidDispatcherType &&
190
+ nextDispatcherType === ContextOnlyDispatcherType;
191
+
192
+ // Update the current dispatcher now so the hooks inside of the
193
+ // useSyncExternalStore shim get the right dispatcher.
194
+ currentDispatcher = nextDispatcher;
195
+ if (isEnteringComponentRender) {
196
+ lock = true;
197
+ const store = usePreactSignalStore(nextDispatcher);
198
+ lock = false;
199
+
200
+ setCurrentUpdater(store.updater);
201
+ } else if (isExitingComponentRender) {
202
+ setCurrentUpdater();
164
203
  }
204
+ },
205
+ });
206
+
207
+ const ValidDispatcherType = 0;
208
+ const ContextOnlyDispatcherType = 1;
209
+ const ErroringDispatcherType = 2;
210
+
211
+ // We inject a useSyncExternalStore into every function component via
212
+ // CurrentDispatcher. This prevents injecting into anything other than a
213
+ // function component render.
214
+ const dispatcherTypeCache = new Map<ReactDispatcher, number>();
215
+ function getDispatcherType(dispatcher: ReactDispatcher | null): number {
216
+ // Treat null the same as the ContextOnlyDispatcher.
217
+ if (!dispatcher) return ContextOnlyDispatcherType;
218
+
219
+ const cached = dispatcherTypeCache.get(dispatcher);
220
+ if (cached !== undefined) return cached;
221
+
222
+ // The ContextOnlyDispatcher sets all the hook implementations to a function
223
+ // that takes no arguments and throws and error. Check the number of arguments
224
+ // for this dispatcher's useCallback implementation to determine if it is a
225
+ // ContextOnlyDispatcher. All other dispatchers, erroring or not, define
226
+ // functions with arguments and so fail this check.
227
+ let type: number;
228
+ if (dispatcher.useCallback.length < 2) {
229
+ type = ContextOnlyDispatcherType;
230
+ } else if (/Invalid/.test(dispatcher.useCallback as any)) {
231
+ type = ErroringDispatcherType;
232
+ } else {
233
+ type = ValidDispatcherType;
234
+ }
165
235
 
236
+ dispatcherTypeCache.set(dispatcher, type);
237
+ return type;
238
+ }
239
+
240
+ function WrapJsx<T>(jsx: T): T {
241
+ if (typeof jsx !== "function") return jsx;
242
+
243
+ return function (type: any, props: any, ...rest: any[]) {
166
244
  if (typeof type === "string" && props) {
167
245
  for (let i in props) {
168
246
  let v = props[i];
@@ -194,6 +272,12 @@ JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
194
272
  JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
195
273
  JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
196
274
 
275
+ declare module "@preact/signals-core" {
276
+ // @ts-ignore internal Signal is viewed as function
277
+ // eslint-disable-next-line @typescript-eslint/no-empty-interface
278
+ interface Signal extends ReactElement {}
279
+ }
280
+
197
281
  /**
198
282
  * A wrapper component that renders a Signal's value directly as a Text node.
199
283
  */
@@ -204,7 +288,7 @@ function Text({ data }: { data: Signal }) {
204
288
  // Decorate Signals so React renders them as <Text> components.
205
289
  Object.defineProperties(Signal.prototype, {
206
290
  $$typeof: { configurable: true, value: ReactElemType },
207
- type: { configurable: true, value: ProxyFunctionalComponent(Text) },
291
+ type: { configurable: true, value: Text },
208
292
  props: {
209
293
  configurable: true,
210
294
  get() {
@@ -229,8 +313,6 @@ export function useSignalEffect(cb: () => void | (() => void)) {
229
313
  callback.current = cb;
230
314
 
231
315
  useEffect(() => {
232
- return effect(() => {
233
- return callback.current();
234
- });
316
+ return effect(() => callback.current());
235
317
  }, Empty);
236
318
  }
package/src/internal.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { Signal } from "@preact/signals-core";
2
-
3
1
  export interface Effect {
4
2
  _sources: object | undefined;
5
3
  _start(): () => void;
@@ -7,8 +5,6 @@ export interface Effect {
7
5
  _dispose(): void;
8
6
  }
9
7
 
10
- export type Updater = Signal<unknown>;
11
-
12
8
  export interface JsxRuntimeModule {
13
9
  jsx?(type: any, ...rest: any[]): unknown;
14
10
  jsxs?(type: any, ...rest: any[]): unknown;