diff --git a/.changeset/shiny-lizards-allow.md b/.changeset/shiny-lizards-allow.md
new file mode 100644
index 000000000..7fdc751b9
--- /dev/null
+++ b/.changeset/shiny-lizards-allow.md
@@ -0,0 +1,20 @@
+---
+"@preact/signals-react": patch
+---
+
+Added reactivity for components wrapped with `React.forwardRef` and `React.lazy`.
+So since this moment, this code will work as expected:
+```tsx
+const sig = signal(0)
+setInterval(() => sig.value++, 1000)
+
+const Lazy = React.lazy(() => Promise.resolve({ default: () =>
{sig.value + 1}
}))
+const Forwarded = React.forwardRef(() => {sig.value + 1}
)
+
+export const App = () => (
+
+
+
+
+)
+```
\ No newline at end of file
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 570656d71..ddc08ff8c 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -4,6 +4,7 @@ import {
useEffect,
Component,
type FunctionComponent,
+ ForwardRefExoticComponent,
} from "react";
import React from "react";
import jsxRuntime from "react/jsx-runtime";
@@ -24,10 +25,16 @@ export { signal, computed, batch, effect, Signal, type ReadonlySignal };
const Empty = [] as const;
const ReactElemType = Symbol.for("react.element"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L15
const ReactMemoType = Symbol.for("react.memo"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L30
+const ReactLazyType = Symbol.for("react.lazy"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L31
+const ReactForwardRefType: symbol = Symbol.for("react.forward_ref"); // https://github.com/facebook/react/blob/346c7d4c43a0717302d446da9e7423a8e28d8996/packages/shared/ReactSymbols.js#L25
const ProxyInstance = new WeakMap<
FunctionComponent,
FunctionComponent
>();
+const ProxyForwardRef = new WeakMap<
+ ForwardRefExoticComponent,
+ ForwardRefExoticComponent
+>();
const SupportsProxy = typeof Proxy === "function";
const ProxyHandlers = {
@@ -67,6 +74,22 @@ const ProxyHandlers = {
function ProxyFunctionalComponent(Component: FunctionComponent) {
return ProxyInstance.get(Component) || WrapWithProxy(Component);
}
+function ProxyForwardRefComponent(Component: ForwardRefExoticComponent) {
+ const current = ProxyForwardRef.get(Component);
+ if (current) {
+ return current;
+ }
+ const WrappedComponent: ForwardRefExoticComponent = {
+ $$typeof: ReactForwardRefType,
+ // @ts-expect-error React lies about ForwardRefExtoricComponent type
+ render: ProxyFunctionalComponent(Component.render),
+ };
+
+ ProxyForwardRef.set(Component, WrappedComponent);
+ ProxyForwardRef.set(WrappedComponent, WrappedComponent);
+
+ return WrappedComponent;
+}
function WrapWithProxy(Component: FunctionComponent) {
if (SupportsProxy) {
const ProxyComponent = new Proxy(Component, ProxyHandlers);
@@ -166,6 +189,22 @@ function WrapJsx(jsx: T): T {
return jsx.call(jsx, type, props, ...rest);
}
+ if (type && typeof type === "object" && type.$$typeof === ReactLazyType) {
+ return jsx.call(
+ jsx,
+ ProxyFunctionalComponent(type._init(type._payload)),
+ props,
+ ...rest
+ );
+ }
+ if (
+ type &&
+ typeof type === "object" &&
+ type.$$typeof === ReactForwardRefType
+ ) {
+ return jsx.call(jsx, ProxyForwardRefComponent(type), props, ...rest);
+ }
+
if (typeof type === "string" && props) {
for (let i in props) {
let v = props[i];
diff --git a/packages/react/test/index.test.tsx b/packages/react/test/index.test.tsx
index a4c7f98ef..beefcfae6 100644
--- a/packages/react/test/index.test.tsx
+++ b/packages/react/test/index.test.tsx
@@ -2,7 +2,16 @@
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
import { signal, useComputed, useSignalEffect } from "@preact/signals-react";
-import { createElement, useMemo, memo, StrictMode, createRef } from "react";
+import {
+ createElement,
+ useMemo,
+ memo,
+ StrictMode,
+ createRef,
+ forwardRef,
+ lazy,
+ Suspense,
+} from "react";
import { createRoot, Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { act } from "react-dom/test-utils";
@@ -160,6 +169,79 @@ describe("@preact/signals-react", () => {
expect(scratch.textContent).to.equal("bar");
});
+ it("should update components wrapped with memo via signals", async () => {
+ const sig = signal("foo");
+
+ const Inner = memo(() => {
+ const value = sig.value;
+ return {value}
;
+ });
+
+ function App() {
+ return ;
+ }
+
+ render();
+ expect(scratch.textContent).to.equal("foo");
+
+ act(() => {
+ sig.value = "bar";
+ });
+ expect(scratch.textContent).to.equal("bar");
+ });
+ it("should update components wrapped with lazy via signals", async () => {
+ const sig = signal("foo");
+
+ const _Inner = () => {
+ const value = sig.value;
+ return {value}
;
+ };
+ let pr: undefined | Promise;
+ const Inner = lazy(() => (pr = Promise.resolve({ default: _Inner })));
+
+ function App() {
+ return (
+
+
+
+ );
+ }
+
+ render();
+ expect(pr).instanceOf(Promise);
+ expect(scratch.textContent).not.to.equal("foo");
+ await act(async () => {
+ await pr;
+ });
+ expect(scratch.textContent).to.equal("foo");
+
+ act(() => {
+ sig.value = "bar";
+ });
+ expect(scratch.textContent).to.equal("bar");
+ });
+
+ it("should update components wrapped with forwardRef via signals", async () => {
+ const sig = signal("foo");
+
+ const Inner = forwardRef(() => {
+ const value = sig.value;
+ return {value}
;
+ });
+
+ function App() {
+ return ;
+ }
+
+ render();
+ expect(scratch.textContent).to.equal("foo");
+
+ act(() => {
+ sig.value = "bar";
+ });
+ expect(scratch.textContent).to.equal("bar");
+ });
+
it("should consistently rerender in strict mode", async () => {
const sig = signal(null!);
@@ -200,6 +282,26 @@ describe("@preact/signals-react", () => {
expect(scratch.textContent).to.equal(value);
}
});
+ it("should consistently rerender in strict mode (with forwardRef)", async () => {
+ const sig = signal(null!);
+
+ const Test = forwardRef(() => {sig.value}
);
+ const App = () => (
+
+
+
+ );
+
+ for (let i = 0; i < 3; i++) {
+ const value = `${i}`;
+
+ act(() => {
+ sig.value = value;
+ render();
+ });
+ expect(scratch.textContent).to.equal(value);
+ }
+ });
it("should render static markup of a component", async () => {
const count = signal(0);