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