diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index eb18d11e9..754a33009 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -4,9 +4,12 @@ import { useSignalEffect, Signal, signal, + useSignal, } from "@preact/signals"; +import type { ReadonlySignal } from "@preact/signals"; import { createElement, createRef, render, createContext } from "preact"; -import { useContext, useState } from "preact/hooks"; +import type { ComponentChildren, FunctionComponent } from "preact"; +import { useContext, useRef, useState } from "preact/hooks"; import { setupRerender, act } from "preact/test-utils"; const sleep = (ms?: number) => new Promise(r => setTimeout(r, ms)); @@ -270,6 +273,110 @@ describe("@preact/signals", () => { expect(spy).to.be.calledOnce; }); + it("should minimize rerenders when passing signals through context", () => { + function spyOn

( + c: FunctionComponent

+ ) { + return sinon.spy(c); + } + + // Manually read signal value below so we can watch whether components rerender + const Origin = spyOn(function Origin() { + const origin = useContext(URLModelContext).origin; + return {origin.value}; + }); + + const Pathname = spyOn(function Pathname() { + const pathname = useContext(URLModelContext).pathname; + return {pathname.value}; + }); + + const Search = spyOn(function Search() { + const search = useContext(URLModelContext).search; + return {search.value}; + }); + + // Never reads signal value during render so should never rerender + const UpdateURL = spyOn(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(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 ( + +

+ + + +

+ + + ); + } + + render(, scratch); + + const url = scratch.querySelector("p")!; + expect(url.textContent).to.equal("https://domain.com/test?a=1"); + expect(URLModelProvider).to.be.calledOnce; + expect(Origin).to.be.calledOnce; + expect(Pathname).to.be.calledOnce; + expect(Search).to.be.calledOnce; + + scratch.querySelector("button")!.click(); + rerender(); + + expect(url.textContent).to.equal("https://domain.com/test?a=2"); + expect(URLModelProvider).to.be.calledOnce; + expect(Origin).to.be.calledOnce; + expect(Pathname).to.be.calledOnce; + expect(Search).to.be.calledTwice; + }); + it("should not subscribe to computed signals only created and not used", () => { const sig = signal(0); const childSpy = sinon.spy(); diff --git a/packages/react/test/browser/updates.test.tsx b/packages/react/test/browser/updates.test.tsx index 8bef459a9..dec3fe8e5 100644 --- a/packages/react/test/browser/updates.test.tsx +++ b/packages/react/test/browser/updates.test.tsx @@ -7,8 +7,8 @@ import { useComputed, useSignalEffect, useSignal, - Signal, } from "@preact/signals-react"; +import type { Signal, ReadonlySignal } from "@preact/signals-react"; import { createElement, Fragment, @@ -21,7 +21,9 @@ import { useState, useContext, createContext, + useRef, } from "react"; +import type { FunctionComponent } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { @@ -509,6 +511,111 @@ describe("@preact/signals-react updating", () => { expect(scratch.innerHTML).to.equal("
1 1
"); }); + it("should minimize rerenders when passing signals through context", async () => { + function spyOn

( + c: FunctionComponent

+ ) { + return sinon.spy(c); + } + + // Manually read signal value below so we can watch whether components rerender + const Origin = spyOn(function Origin() { + const origin = useContext(URLModelContext).origin; + return {origin.value}; + }); + + const Pathname = spyOn(function Pathname() { + const pathname = useContext(URLModelContext).pathname; + return {pathname.value}; + }); + + const Search = spyOn(function Search() { + const search = useContext(URLModelContext).search; + return {search.value}; + }); + + // Never reads signal value during render so should never rerender + const UpdateURL = spyOn(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(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).to.equal("https://domain.com/test?a=1"); + expect(URLModelProvider).to.be.calledOnce; + expect(Origin).to.be.calledOnce; + expect(Pathname).to.be.calledOnce; + expect(Search).to.be.calledOnce; + + await act(() => { + scratch.querySelector("button")!.click(); + }); + + expect(url.textContent).to.equal("https://domain.com/test?a=2"); + expect(URLModelProvider).to.be.calledOnce; + expect(Origin).to.be.calledOnce; + expect(Pathname).to.be.calledOnce; + expect(Search).to.be.calledTwice; + }); + it("should not subscribe to computed signals only created and not used", async () => { const sig = signal(0); const childSpy = sinon.spy();