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);