import { signal, computed, useComputed, useSignalEffect, useSignal, } from "../../src/lib"; import type { Signal, ReadonlySignal } from "../../src/lib"; import { useSignals } from "../../src/lib/tracking"; import React, { Fragment, forwardRef, useMemo, useReducer, memo, StrictMode, createRef, useState, useContext, createContext, useRef, } from "react"; import type { FunctionComponent } from "react"; import { describe, it, beforeEach, afterEach, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { createRoot, Root, act, checkHangingAct, isReact16, isProd, getConsoleErrorSpy, checkConsoleErrorLogs, } from "../shared/utils"; vi.mock("../../src/lib/tracking", async (importOriginal) => ({ useSignals: vi.fn( (await importOriginal()).useSignals ), })); describe("@preact/signals-react updating", () => { let scratch: HTMLDivElement; let root: Root; async function render(element: Parameters[0]) { await act(() => root.render(element)); } beforeEach(async () => { scratch = document.createElement("div"); document.body.appendChild(scratch); root = await createRoot(scratch); getConsoleErrorSpy().mockReset(); }); afterEach(async () => { await act(() => root.unmount()); scratch.remove(); checkConsoleErrorLogs(); checkHangingAct(); }); describe("SignalValue bindings", () => { it("should render text without signals", async () => { await render(test); const span = scratch.firstChild; const text = span?.firstChild; expect(text).to.have.property("data", "test"); }); it("should render Signals as SignalValue", async () => { const sig = signal("test"); await render({sig}); const span = scratch.firstChild; expect(span).to.have.property("firstChild").that.is.an.instanceOf(Text); const text = span?.firstChild; expect(text).to.have.property("data", "test"); }); it("should render computed as SignalValue", async () => { const sig = signal("test"); const comp = computed(() => `${sig} ${sig}`); await render({comp}); const span = scratch.firstChild; expect(span).to.have.property("firstChild").that.is.an.instanceOf(Text); const text = span?.firstChild; expect(text).to.have.property("data", "test test"); }); it("should update Signal-based SignalValue (no parent component)", async () => { const sig = signal("test"); await render({sig}); const text = scratch.firstChild!.firstChild!; expect(text).to.have.property("data", "test"); await act(() => { sig.value = "changed"; }); // should not remount/replace SignalValue expect(scratch.firstChild!.firstChild!).to.equal(text); // should update the text in-place expect(text).to.have.property("data", "changed"); }); it("should update Signal-based SignalValue (in a parent component)", async () => { const sig = signal("test"); function App({ x }: { x: typeof sig }) { return {x}; } await render(); const text = scratch.firstChild!.firstChild!; expect(text).to.have.property("data", "test"); await act(() => { sig.value = "changed"; }); // should not remount/replace SignalValue expect(scratch.firstChild!.firstChild!).to.equal(text); // should update the text in-place expect(text).to.have.property("data", "changed"); }); it("should work with JSX inside signal", async () => { const sig = signal(test); function App({ x }: { x: typeof sig }) { return {x}; } await render(); let text = scratch.firstChild!.firstChild!; expect(text).to.be.instanceOf(HTMLElement); expect(text.firstChild).to.have.property("data", "test"); await act(() => { sig.value =
changed
; }); text = scratch.firstChild!.firstChild!; expect(text).to.be.instanceOf(HTMLDivElement); expect(text.firstChild).to.have.property("data", "changed"); }); }); describe("Component bindings", () => { it("should subscribe to signals", async () => { const _sig = signal("foo"); /** * * @useSignals **/ function App() { const value = _sig.value; return

{value}

; } await render(); expect(scratch.textContent).to.equal("foo"); await act(() => { _sig.value = "bar"; }); expect(scratch.textContent).to.equal("bar"); }); it("should rerender components when signals they use change", async () => { const signal1 = signal(0); function Child1() { return
{signal1}
; } const signal2 = signal(0); function Child2() { return
{signal2}
; } function Parent() { return ( ); } await render(); expect(scratch.innerHTML).to.equal("
0
0
"); await act(() => { signal1.value += 1; }); expect(scratch.innerHTML).to.equal("
1
0
"); await act(() => { signal2.value += 1; }); expect(scratch.innerHTML).to.equal("
1
1
"); }); it("should subscribe to signals passed as props to DOM elements", async () => { const className = signal("foo"); /** * * @useSignals **/ function App() { // @ts-expect-error React types don't allow signals on DOM elements :/ return
; } await render(); expect(scratch.innerHTML).to.equal('
'); await act(() => { className.value = "bar"; }); expect(scratch.innerHTML).to.equal('
'); }); it("should activate signal accessed in render", async () => { const sig = signal(null); function App() { const arr = useComputed(() => { // trigger read sig.value; return []; }); const str = arr.value.join(", "); return

{str}

; } try { await render(); } catch (e: any) { expect.fail(e.stack); } }); it("should not subscribe to child signals", async () => { const sig = signal("foo"); /** * * @useSignals **/ function Child() { const value = sig.value; return

{value}

; } const spy = vi.fn(); function App() { spy(); return ; } await render(); expect(scratch.textContent).toBe("foo"); await act(() => { sig.value = "bar"; }); expect(spy).toHaveBeenCalledTimes(1); }); it("should update memo'ed component via signals", async () => { const sig = signal("foo"); /** * * @useSignals **/ function Inner() { const value = sig.value; return

{value}

; } /** * * @useSignals **/ function App() { sig.value; return useMemo(() => , []); } await render(); expect(scratch.textContent).to.equal("foo"); await act(() => { sig.value = "bar"; }); expect(scratch.textContent).to.equal("bar"); }); it("should update forwardRef'ed component via signals", async () => { const sig = signal("foo"); const Inner = forwardRef( /** * * @useSignals **/ () => { return

{sig.value}

; } ); function App() { return ; } await render(); expect(scratch.textContent).to.equal("foo"); await act(() => { sig.value = "bar"; }); expect(scratch.textContent).to.equal("bar"); }); it("should consistently rerender in strict mode", async () => { const sig = signal(-1); /** * * @useSignals **/ const Test = () =>

{sig.value}

; const App = () => ( ); await render(); expect(scratch.textContent).to.equal("-1"); for (let i = 0; i < 3; i++) { await act(async () => { sig.value = i; }); expect(scratch.textContent).to.equal("" + i); } }); it("should consistently rerender in strict mode (with memo)", async () => { const sig = signal(-1); const Test = memo( /** * * @useSignals **/ () =>

{sig.value}

); const App = () => ( ); await render(); expect(scratch.textContent).to.equal("-1"); for (let i = 0; i < 3; i++) { await act(async () => { sig.value = i; }); expect(scratch.textContent).to.equal("" + i); } }); it("should render static markup of a component", async () => { const count = signal(0); /** * * @useSignals **/ const Test = () => { return (
            {renderToStaticMarkup({count})}
            {renderToStaticMarkup({count.value})}
          
); }; await render(); expect(scratch.textContent).to.equal("00"); for (let i = 0; i < 3; i++) { await act(async () => { count.value += 1; }); expect(scratch.textContent).to.equal( `${count.value}${count.value}` ); } }); it("should correctly render components that have useReducer()", async () => { const count = signal(0); let increment: () => void; /** * * @useSignals **/ const Test = () => { const [state, dispatch] = useReducer( (state: number, action: number) => { return state + action; }, -2 ); increment = () => dispatch(1); const doubled = count.value * 2; return (
            {state}
            {doubled}
          
); }; await render(); expect(scratch.innerHTML).to.equal( "
-20
" ); for (let i = 0; i < 3; i++) { await act(async () => { count.value += 1; }); expect(scratch.innerHTML).to.equal( `
-2${count.value * 2}
` ); } await act(() => { increment(); }); expect(scratch.innerHTML).to.equal( `
-1${count.value * 2}
` ); }); it("should not fail when a component calls setState while rendering", async () => { let increment: () => void; function App() { const [state, setState] = useState(0); increment = () => setState(state + 1); if (state > 0 && state < 2) { setState(state + 1); } return
{state}
; } await render(); expect(scratch.innerHTML).to.equal("
0
"); await act(() => { increment(); }); expect(scratch.innerHTML).to.equal("
2
"); }); it("should not fail when a component calls setState multiple times while rendering", async () => { let increment: () => void; function App() { const [state, setState] = useState(0); increment = () => setState(state + 1); if (state > 0 && state < 5) { setState(state + 1); } return
{state}
; } await render(); expect(scratch.innerHTML).to.equal("
0
"); await act(() => { increment(); }); expect(scratch.innerHTML).to.equal("
5
"); }); it("should not fail when a component only uses state-less hooks", async () => { // This test is suppose to trigger a condition in React where the // HooksDispatcherOnMountWithHookTypesInDEV is used. This dispatcher is // used in the development build of React if a component has hook types // defined but no memoizedState, meaning no stateful hooks (e.g. useState) // are used. `useContext` is an example of a state-less hook because it // does not mount any hook state onto the fiber's memoizedState field. // // However, as of writing, because our react adapter inserts a // useSyncExternalStore into all components, all components have memoized // state and so this condition is never hit. However, I'm leaving the test // to capture this unique behavior to hopefully catch any errors caused by // not understanding or handling this in the future. const sig = signal(0); const MyContext = createContext(0); function Child() { const value = useContext(MyContext); return (
{sig} {value}
); } let updateContext: () => void; function App() { const [value, setValue] = useState(0); updateContext = () => setValue(value + 1); return ( ); } await render(); expect(scratch.innerHTML).to.equal("
0 0
"); await act(() => { sig.value++; }); expect(scratch.innerHTML).to.equal("
1 0
"); await act(() => { updateContext(); }); expect(scratch.innerHTML).to.equal("
1 1
"); }); it("should minimize rerenders when passing signals through context", async () => { function spyOn

( c: FunctionComponent

) { return vi.fn(c); } // Manually read signal value below so we can watch whether components rerender const Origin = spyOn( /** * * @useSignals **/ function Origin() { const origin = useContext(URLModelContext).origin; return {origin.value}; } ); const Pathname = spyOn( /** * * @useSignals **/ function Pathname() { const pathname = useContext(URLModelContext).pathname; return {pathname.value}; } ); const Search = spyOn( /** * * @useSignals **/ function Search() { const search = useContext(URLModelContext).search; return {search.value}; } ); // Never reads signal value during render so should never rerender const UpdateURL = spyOn( /** * * @useSignals **/ function UpdateURL() { const update = useContext(URLModelContext).update; return ( ); } ); interface URLModel { origin: ReadonlySignal; pathname: ReadonlySignal; search: ReadonlySignal; update(updater: (newURL: URL) => void): void; } // Also never reads signal value during render so should never rerender const URLModelContext = createContext(null as any); const URLModelProvider = spyOn( /** * * @useSignals **/ function SignalProvider({ children }) { const url = useSignal(new URL("https://domain.com/test?a=1")); const modelRef = useRef(null); if (modelRef.current == null) { modelRef.current = { origin: computed(() => url.value.origin), pathname: computed(() => url.value.pathname), search: computed(() => url.value.search), update(updater) { const newURL = new URL(url.value); updater(newURL); url.value = newURL; }, }; } return ( {children} ); } ); function App() { return (

); } await render(); const url = scratch.querySelector("p")!; expect(url.textContent).toBe("https://domain.com/test?a=1"); expect(URLModelProvider).toHaveBeenCalledTimes(1); expect(Origin).toHaveBeenCalledTimes(1); expect(Pathname).toHaveBeenCalledTimes(1); expect(Search).toHaveBeenCalledTimes(1); await act(() => { scratch.querySelector("button")!.click(); }); expect(url.textContent).toBe("https://domain.com/test?a=2"); expect(URLModelProvider).toHaveBeenCalledTimes(1); expect(Origin).toHaveBeenCalledTimes(1); expect(Pathname).toHaveBeenCalledTimes(1); expect(Search).toHaveBeenCalledTimes(2); }); it("should not subscribe to computed signals only created and not used", async () => { const sig = signal(0); const childSpy = vi.fn(); const parentSpy = vi.fn(); /** * * @useSignals **/ function Child({ num }: { num: Signal }) { childSpy(); return

{num.value}

; } /** * * @useSignals **/ function Parent({ num }: { num: Signal }) { parentSpy(); const sig2 = useComputed(() => num.value + 1); return ; } await render(); expect(scratch.innerHTML).toBe("

1

"); expect(parentSpy).toHaveBeenCalledTimes(1); expect(childSpy).toHaveBeenCalledTimes(1); await act(() => { sig.value += 1; }); expect(scratch.innerHTML).toBe("

2

"); expect(parentSpy).toHaveBeenCalledTimes(1); expect(childSpy).toHaveBeenCalledTimes(2); }); it("should properly subscribe and unsubscribe to conditionally rendered computed signals ", async () => { const computedDep = signal(0); const renderComputed = signal(true); const renderSpy = vi.fn(); /** * * @useSignals **/ function App() { renderSpy(); const computed = useComputed(() => computedDep.value + 1); return renderComputed.value ?

{computed.value}

: null; } await render(); expect(scratch.innerHTML).toBe("

1

"); expect(renderSpy).toHaveBeenCalledTimes(1); await act(() => { computedDep.value += 1; }); expect(scratch.innerHTML).toBe("

2

"); expect(renderSpy).toHaveBeenCalledTimes(2); await act(() => { renderComputed.value = false; }); expect(scratch.innerHTML).toBe(""); expect(renderSpy).toHaveBeenCalledTimes(3); await act(() => { computedDep.value += 1; }); expect(scratch.innerHTML).toBe(""); expect(renderSpy).toHaveBeenCalledTimes(3); // Should not be called again }); describe("useSignal()", () => { it("should create a signal from a primitive value", async () => { /** * * @useSignals **/ function App() { const count = useSignal(1); return (
{count}
); } await render(); expect(scratch.textContent).to.equal("1Increment"); await act(() => { scratch.querySelector("button")!.click(); }); expect(scratch.textContent).to.equal("2Increment"); }); }); describe("useSignalEffect()", () => { it("should be invoked after commit", async () => { const ref = createRef(); const sig = signal("foo"); const spy = vi.fn(); let count = 0; /** * * @useSignals **/ function App() { useSignalEffect(() => spy( sig.value, ref.current, ref.current!.getAttribute("data-render-id") ) ); return (

{sig.value}

); } await render(); expect(scratch.textContent).toBe("foo"); expect(spy).toHaveBeenCalledWith("foo", scratch.firstElementChild, "0"); spy.mockReset(); await act(() => { sig.value = "bar"; }); expect(scratch.textContent).toBe("bar"); expect(spy).toHaveBeenCalledWith( "bar", scratch.firstElementChild, isReact16 && isProd ? "1" : "0" ); }); it("should invoke any returned cleanup function for updates", async () => { const ref = createRef(); const sig = signal("foo"); const spy = vi.fn(); const cleanup = vi.fn(); let count = 0; /** * * @useSignals **/ function App() { useSignalEffect(() => { const id = ref.current!.getAttribute("data-render-id"); const value = sig.value; spy(value, ref.current, id); return () => cleanup(value, ref.current, id); }); return (

{sig.value}

); } await render(); expect(cleanup).not.toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith("foo", scratch.firstElementChild, "0"); spy.mockReset(); await act(() => { sig.value = "bar"; }); expect(scratch.textContent).toBe("bar"); const child = scratch.firstElementChild; expect(cleanup).toHaveBeenCalledWith("foo", child, "0"); expect(spy).toHaveBeenCalledWith( "bar", child, isReact16 && isProd ? "1" : "0" ); }); it("should invoke any returned cleanup function for unmounts", async () => { const ref = createRef(); const sig = signal("foo"); const spy = vi.fn(); const cleanup = vi.fn(); /** * * @useSignals **/ function App() { useSignalEffect(() => { const value = sig.value; spy(value, ref.current); return () => cleanup(value, ref.current); }); return

{sig.value}

; } await render(); const child = scratch.firstElementChild; expect(scratch.innerHTML).toBe("

foo

"); expect(cleanup).not.toHaveBeenCalled(); expect(spy).toHaveBeenCalledWith("foo", child); spy.mockReset(); await act(() => { root.unmount(); }); expect(scratch.innerHTML).toBe(""); expect(spy).not.toHaveBeenCalled(); expect(cleanup).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledWith("foo", isReact16 ? child : null); }); }); }); it("transform should not touch hooks that uses signals", async () => { vi.mocked(useSignals).mockClear(); /** * * @useSignals **/ function App() { return

{useTest()}

; } await render(); expect(scratch.textContent).to.equal("foo"); expect(useSignals).toHaveBeenCalledOnce(); }); }); function useTest() { const sig = signal("foo"); return sig.value; }