@preact/signals-react 1.2.2 → 1.3.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.
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,87 +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 WeakMap<
28
- FunctionComponent<any>,
29
- FunctionComponent<any>
30
- >();
31
- const SupportsProxy = typeof Proxy === "function";
32
-
33
- const ProxyHandlers = {
34
- /**
35
- * This is a function call trap for functional components.
36
- * When this is called, we know it means React did run 'Component()',
37
- * that means we can use any hooks here to setup our effect and store.
38
- *
39
- * With the native Proxy, all other calls such as access/setting to/of properties will
40
- * be forwarded to the target Component, so we don't need to copy the Component's
41
- * own or inherited properties.
42
- *
43
- * @see https://github.com/facebook/react/blob/2d80a0cd690bb5650b6c8a6c079a87b5dc42bd15/packages/react-reconciler/src/ReactFiberHooks.old.js#L460
44
- */
45
- apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
46
- const store = useMemo(createEffectStore, Empty);
47
-
48
- useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
49
-
50
- const stop = store.updater._start();
51
-
52
- try {
53
- const children = Component.apply(thisArg, argumentsList);
54
- return children;
55
- } catch (e) {
56
- // Re-throwing promises that'll be handled by suspense
57
- // or an actual error.
58
- throw e;
59
- } finally {
60
- // Stop effects in either case before return or throw,
61
- // Otherwise the effect will leak.
62
- stop();
63
- }
64
- },
65
- };
66
30
 
67
- function ProxyFunctionalComponent(Component: FunctionComponent<any>) {
68
- 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;
69
36
  }
70
- function WrapWithProxy(Component: FunctionComponent<any>) {
71
- if (SupportsProxy) {
72
- const ProxyComponent = new Proxy(Component, ProxyHandlers);
73
37
 
74
- ProxyInstance.set(Component, ProxyComponent);
75
- ProxyInstance.set(ProxyComponent, ProxyComponent);
38
+ let finishUpdate: (() => void) | undefined;
76
39
 
77
- return ProxyComponent;
78
- }
79
-
80
- /**
81
- * Emulate a Proxy if environment doesn't support it.
82
- *
83
- * @TODO - unlike Proxy, it's not possible to access the type/Component's
84
- * static properties this way. Not sure if we want to copy all statics here.
85
- * Omitting this for now.
86
- *
87
- * @example - works with Proxy, doesn't with wrapped function.
88
- * ```
89
- * const el = <SomeFunctionalComponent />
90
- * el.type.someOwnOrInheritedProperty;
91
- * el.type.defaultProps;
92
- * ```
93
- */
94
- const WrappedComponent = function () {
95
- return ProxyHandlers.apply(Component, undefined, arguments);
96
- };
97
- ProxyInstance.set(Component, WrappedComponent);
98
- 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
+ }
99
46
 
100
- return WrappedComponent;
47
+ interface EffectStore {
48
+ updater: Effect;
49
+ subscribe(onStoreChange: () => void): () => void;
50
+ getSnapshot(): number;
101
51
  }
102
52
 
103
53
  /**
@@ -110,10 +60,10 @@ function WrapWithProxy(Component: FunctionComponent<any>) {
110
60
  * we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
111
61
  *
112
62
  * [1]
113
- * @see https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
63
+ * @see https://react.dev/reference/react/useSyncExternalStore
114
64
  * @see https://github.com/reactjs/rfcs/blob/main/text/0214-use-sync-external-store.md
115
65
  */
116
- function createEffectStore() {
66
+ function createEffectStore(): EffectStore {
117
67
  let updater!: Effect;
118
68
  let version = 0;
119
69
  let onChangeNotifyReact: (() => void) | undefined;
@@ -128,7 +78,7 @@ function createEffectStore() {
128
78
 
129
79
  return {
130
80
  updater,
131
- subscribe(onStoreChange: () => void) {
81
+ subscribe(onStoreChange) {
132
82
  onChangeNotifyReact = onStoreChange;
133
83
 
134
84
  return function () {
@@ -153,19 +103,144 @@ function createEffectStore() {
153
103
  };
154
104
  }
155
105
 
156
- function WrapJsx<T>(jsx: T): T {
157
- 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
+ }
158
115
 
159
- return function (type: any, props: any, ...rest: any[]) {
160
- if (typeof type === "function" && !(type instanceof Component)) {
161
- 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;
162
175
  }
163
176
 
164
- if (type && typeof type === "object" && type.$$typeof === ReactMemoType) {
165
- type.type = ProxyFunctionalComponent(type.type);
166
- 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();
167
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
+ }
168
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[]) {
169
244
  if (typeof type === "string" && props) {
170
245
  for (let i in props) {
171
246
  let v = props[i];
@@ -197,6 +272,12 @@ JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
197
272
  JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
198
273
  JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
199
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
+
200
281
  /**
201
282
  * A wrapper component that renders a Signal's value directly as a Text node.
202
283
  */
@@ -207,7 +288,7 @@ function Text({ data }: { data: Signal }) {
207
288
  // Decorate Signals so React renders them as <Text> components.
208
289
  Object.defineProperties(Signal.prototype, {
209
290
  $$typeof: { configurable: true, value: ReactElemType },
210
- type: { configurable: true, value: ProxyFunctionalComponent(Text) },
291
+ type: { configurable: true, value: Text },
211
292
  props: {
212
293
  configurable: true,
213
294
  get() {
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;