Skip to content

Commit d4f1d7a

Browse files
committed
wrap type in proxy
1 parent e3ec27d commit d4f1d7a

File tree

2 files changed

+122
-124
lines changed

2 files changed

+122
-124
lines changed

packages/react/src/index.ts

Lines changed: 116 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import {
22
useRef,
33
useMemo,
44
useEffect,
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 internals,
5+
Component,
6+
type FunctionComponent,
87
} from "react";
98
import React from "react";
9+
import jsxRuntime from "react/jsx-runtime";
10+
import jsxRuntimeDev from "react/jsx-dev-runtime";
1011
import { useSyncExternalStore } from "use-sync-external-store/shim";
1112
import {
1213
signal,
@@ -16,65 +17,80 @@ import {
1617
Signal,
1718
type ReadonlySignal,
1819
} from "@preact/signals-core";
19-
import { Effect, ReactDispatcher } from "./internal";
20+
import type { Effect, JsxRuntimeModule } from "./internal";
2021

2122
export { signal, computed, batch, effect, Signal, type ReadonlySignal };
2223

2324
const Empty = [] as const;
24-
25-
/**
26-
* React uses a different entry-point depending on NODE_ENV env var
27-
*/
28-
const __DEV__ = process.env.NODE_ENV !== "production";
29-
30-
/**
31-
* Install a middleware into React.createElement to replace any Signals in props with their value.
32-
* @todo this likely needs to be duplicated for jsx()...
33-
*/
34-
const createElement = React.createElement;
35-
// @ts-ignore-next-line
36-
React.createElement = function (type, props) {
37-
if (typeof type === "string" && props) {
38-
for (let i in props) {
39-
let v = props[i];
40-
if (i !== "children" && v instanceof Signal) {
41-
// createPropUpdater(props, i, v);
42-
props[i] = v.value;
43-
}
44-
}
45-
}
46-
// @ts-ignore-next-line
47-
return createElement.apply(this, arguments);
25+
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+
apply(Component: FunctionComponent, thisArg: any, argumentsList: any) {
41+
const store = useMemo(createEffectStore, Empty);
42+
43+
useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
44+
45+
const ends = store.updater._start();
46+
const kids = Component.apply(thisArg, argumentsList);
47+
ends();
48+
49+
return kids;
50+
},
4851
};
4952

50-
/*
51-
// This breaks React's controlled components implementation
52-
function createPropUpdater(props: any, prop: string, signal: Signal) {
53-
let ref = props.ref;
54-
if (!ref) ref = props.ref = React.createRef();
55-
effect(() => {
56-
if (props) props[prop] = signal.value;
57-
let el = ref.current;
58-
if (!el) return; // unsubscribe
59-
(el as any)[prop] = signal.value;
60-
});
61-
props = null;
53+
function ProxyFunctionalComponent(Component: FunctionComponent<any>) {
54+
return ProxyInstance.get(Component) || WrapWithProxy(Component);
6255
}
63-
*/
56+
function WrapWithProxy(Component: FunctionComponent<any>) {
57+
if (SupportsProxy) {
58+
const ProxyComponent = new Proxy(Component, ProxyHandlers);
6459

65-
let finishUpdate: (() => void) | undefined;
60+
ProxyInstance.set(Component, ProxyComponent);
61+
ProxyInstance.set(ProxyComponent, ProxyComponent);
6662

67-
function setCurrentUpdater(updater?: Effect) {
68-
// end tracking for the current update:
69-
if (finishUpdate) finishUpdate();
70-
// start tracking the new update:
71-
finishUpdate = updater && updater._start();
63+
return ProxyComponent;
64+
}
65+
66+
/**
67+
* Emulate a Proxy if environment doesn't support it.
68+
*
69+
* @TODO - unlike Proxy, it's not possible to access the type/Component's
70+
* static properties this way. Not sure if we want to copy all statics here.
71+
* Omitting this for now.
72+
*
73+
* @example - works with Proxy, doesn't with wrapped function.
74+
* ```
75+
* const el = <SomeFunctionalComponent />
76+
* el.type.someOwnOrInheritedProperty;
77+
* el.type.defaultProps;
78+
* ```
79+
*/
80+
const WrappedComponent = function () {
81+
return ProxyHandlers.apply(Component, undefined, arguments);
82+
};
83+
ProxyInstance.set(Component, WrappedComponent);
84+
ProxyInstance.set(WrappedComponent, WrappedComponent);
85+
86+
return WrappedComponent;
7287
}
7388

7489
/**
75-
* A redux-like store whose store value is a positive 32bit integer (a 'version') to be used with useSyncExternalStore API.
76-
* React (current owner) subscribes to this store and gets a snapshot of the current 'version'.
77-
* Whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
90+
* A redux-like store whose store value is a positive 32bit integer (a 'version').
91+
*
92+
* React subscribes to this store and gets a snapshot of the current 'version',
93+
* whenever the 'version' changes, we tell React it's time to update the component (call 'onStoreChange').
7894
*
7995
* How we achieve this is by creating a binding with an 'effect', when the `effect._callback' is called,
8096
* we update our store version and tell React to re-render the component ([1] We don't really care when/how React does it).
@@ -88,24 +104,14 @@ function createEffectStore() {
88104
let version = 0;
89105
let onChangeNotifyReact: (() => void) | undefined;
90106

91-
const unsubscribe = effect(function (this: Effect) {
107+
let unsubscribe = effect(function (this: Effect) {
92108
updater = this;
93109
});
94-
95110
updater._callback = function () {
96-
if (!onChangeNotifyReact) {
97-
/**
98-
* In dev, lazily unsubscribe self if React isn't subscribed to the store,
99-
* in other words, if the component is not mounted anymore.
100-
*
101-
* We do this to deal with StrictMode double rendering React quirks.
102-
* Only one of the renders is actually mounted.
103-
*/
104-
return void unsubscribe();
105-
}
111+
if (!onChangeNotifyReact) return void unsubscribe();
106112

107113
version = (version + 1) | 0;
108-
onChangeNotifyReact();
114+
onChangeNotifyReact!();
109115
};
110116

111117
return {
@@ -115,11 +121,9 @@ function createEffectStore() {
115121

116122
return function () {
117123
/**
118-
* In StrictMode (in dev mode), React will play with subscribe/unsubscribe/subscribe in double renders,
119-
* We don't really want to unsubscribe during React's play-time and can't reliably know which of renders
120-
* will end up actually being mounted, so we defer unsubscribe to the updater._callback.
124+
* @todo - we may want to unsubscribe here instead?
125+
* Components wrapped in `memo` no longer get updated if we unsubscribe here.
121126
*/
122-
if (!__DEV__) unsubscribe();
123127
onChangeNotifyReact = undefined;
124128
};
125129
},
@@ -129,6 +133,49 @@ function createEffectStore() {
129133
};
130134
}
131135

136+
function WrapJsx<T>(jsx: T): T {
137+
if (typeof jsx !== "function") return jsx;
138+
139+
return function (type: any, props: any, ...rest: any[]) {
140+
if (typeof type === "function" && !(type instanceof Component)) {
141+
return jsx.call(jsx, ProxyFunctionalComponent(type), props, ...rest);
142+
}
143+
144+
if (type && typeof type === "object" && type.$$typeof === ReactMemoType) {
145+
return jsx.call(jsx, ProxyFunctionalComponent(type.type), props, ...rest);
146+
}
147+
148+
if (typeof type === "string" && props) {
149+
for (let i in props) {
150+
let v = props[i];
151+
if (i !== "children" && v instanceof Signal) {
152+
props[i] = v.value;
153+
}
154+
}
155+
}
156+
157+
return jsx.call(jsx, type, props, ...rest);
158+
} as any as T;
159+
}
160+
161+
const JsxPro: JsxRuntimeModule = jsxRuntime;
162+
const JsxDev: JsxRuntimeModule = jsxRuntimeDev;
163+
164+
/**
165+
* createElement _may_ be called by jsx runtime as a fallback in certain cases,
166+
* so we need to wrap it regardless.
167+
*
168+
* The jsx exports depend on the `NODE_ENV` var to ensure the users' bundler doesn't
169+
* include both, so one of them will be set with `undefined` values.
170+
*/
171+
React.createElement = WrapJsx(React.createElement);
172+
JsxDev.jsx && /* */ (JsxDev.jsx = WrapJsx(JsxDev.jsx));
173+
JsxPro.jsx && /* */ (JsxPro.jsx = WrapJsx(JsxPro.jsx));
174+
JsxDev.jsxs && /* */ (JsxDev.jsxs = WrapJsx(JsxDev.jsxs));
175+
JsxPro.jsxs && /* */ (JsxPro.jsxs = WrapJsx(JsxPro.jsxs));
176+
JsxDev.jsxDEV && /**/ (JsxDev.jsxDEV = WrapJsx(JsxDev.jsxDEV));
177+
JsxPro.jsxDEV && /**/ (JsxPro.jsxDEV = WrapJsx(JsxPro.jsxDEV));
178+
132179
/**
133180
* A wrapper component that renders a Signal's value directly as a Text node.
134181
*/
@@ -137,11 +184,9 @@ function Text({ data }: { data: Signal }) {
137184
}
138185

139186
// Decorate Signals so React renders them as <Text> components.
140-
//@ts-ignore-next-line
141-
const $$typeof = createElement("a").$$typeof;
142187
Object.defineProperties(Signal.prototype, {
143-
$$typeof: { configurable: true, value: $$typeof },
144-
type: { configurable: true, value: Text },
188+
$$typeof: { configurable: true, value: ReactElemType },
189+
type: { configurable: true, value: ProxyFunctionalComponent(Text) },
145190
props: {
146191
configurable: true,
147192
get() {
@@ -151,52 +196,6 @@ Object.defineProperties(Signal.prototype, {
151196
ref: { configurable: true, value: null },
152197
});
153198

154-
// Track the current dispatcher (roughly equiv to current component impl)
155-
let lock = false;
156-
let currentDispatcher: ReactDispatcher;
157-
158-
Object.defineProperty(internals.ReactCurrentDispatcher, "current", {
159-
get() {
160-
return currentDispatcher;
161-
},
162-
set(api: ReactDispatcher) {
163-
currentDispatcher = api;
164-
if (lock) return;
165-
if (api && !isInvalidHookAccessor(api)) {
166-
// prevent re-injecting useMemo & useSyncExternalStore when the Dispatcher
167-
// context changes.
168-
lock = true;
169-
170-
const store = api.useMemo(createEffectStore, Empty);
171-
172-
useSyncExternalStore(
173-
store.subscribe,
174-
store.getSnapshot,
175-
store.getSnapshot
176-
);
177-
178-
lock = false;
179-
180-
setCurrentUpdater(store.updater);
181-
} else {
182-
setCurrentUpdater();
183-
}
184-
},
185-
});
186-
187-
// We inject a useReducer into every function component via CurrentDispatcher.
188-
// This prevents injecting into anything other than a function component render.
189-
const invalidHookAccessors = new Map();
190-
function isInvalidHookAccessor(api: ReactDispatcher) {
191-
const cached = invalidHookAccessors.get(api);
192-
if (cached !== undefined) return cached;
193-
// we only want the real implementation, not the warning ones
194-
const invalid =
195-
api.useCallback.length < 2 || /Invalid/.test(api.useCallback as any);
196-
invalidHookAccessors.set(api, invalid);
197-
return invalid;
198-
}
199-
200199
export function useSignal<T>(value: T) {
201200
return useMemo(() => signal<T>(value), Empty);
202201
}

packages/react/src/internal.d.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Signal } from "@preact/signals-core";
2-
import type { useCallback, useMemo, useSyncExternalStore } from "react"
32

43
export interface Effect {
54
_sources: object | undefined;
@@ -8,10 +7,10 @@ export interface Effect {
87
_dispose(): void;
98
}
109

11-
export interface ReactDispatcher {
12-
useCallback: typeof useCallback;
13-
useMemo: typeof useMemo;
14-
useSyncExternalStore: typeof useSyncExternalStore;
15-
}
16-
1710
export type Updater = Signal<unknown>;
11+
12+
export interface JsxRuntimeModule {
13+
jsx?(type: any, ...rest: any[]): unknown;
14+
jsxs?(type: any, ...rest: any[]): unknown;
15+
jsxDEV?(type: any, ...rest: any[]): unknown;
16+
}

0 commit comments

Comments
 (0)