diff --git a/.changeset/loud-dolls-confess.md b/.changeset/loud-dolls-confess.md new file mode 100644 index 000000000..06f77dc2f --- /dev/null +++ b/.changeset/loud-dolls-confess.md @@ -0,0 +1,6 @@ +--- +"@preact/signals": major +"@preact/signals-react": major +--- + +Add useWatcher hook diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 5171394ae..8f17f0cd3 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -67,10 +67,9 @@ function Text(this: AugmentedComponent, { data }: { data: Signal }) { // Store the props.data signal in another signal so that // passing a new signal reference re-runs the text computed: - const currentSignal = useSignal(data); - currentSignal.value = data; + const currentSignal = useWatcher(data); - const s = useMemo(() => { + const $s = useMemo(() => { // mark the parent component as having computeds so it gets optimized let v = this.__v; while ((v = v.__!)) { @@ -82,17 +81,16 @@ function Text(this: AugmentedComponent, { data }: { data: Signal }) { // Replace this component's vdom updater with a direct text one: this._updater!._callback = () => { - (this.base as Text).data = s.peek(); + (this.base as Text).data = $s.peek(); }; return computed(() => { - let data = currentSignal.value; - let s = data.value; + const s = currentSignal.value.value; return s === 0 ? 0 : s === true ? "" : s || ""; }); }, []); - return s.value; + return $s.value; } Text.displayName = "_st"; @@ -114,7 +112,7 @@ Object.defineProperties(Signal.prototype, { /** Inject low-level property/attribute bindings for Signals into Preact's diff */ hook(OptionsTypes.DIFF, (old, vnode) => { if (typeof vnode.type === "string") { - let signalProps: Record | undefined; + let signalProps: typeof vnode.__np; let props = vnode.props; for (let i in props) { @@ -136,7 +134,7 @@ hook(OptionsTypes.DIFF, (old, vnode) => { hook(OptionsTypes.RENDER, (old, vnode) => { setCurrentUpdater(); - let updater; + let updater: Effect | undefined; let component = vnode.__c; if (component) { @@ -187,15 +185,18 @@ hook(OptionsTypes.DIFFED, (old, vnode) => { } } } else { - updaters = {}; - dom._updaters = updaters; + dom._updaters = updaters = {}; } for (let prop in props) { let updater = updaters[prop]; let signal = props[prop]; if (updater === undefined) { - updater = createPropUpdater(dom, prop, signal, renderedProps); - updaters[prop] = updater; + updaters[prop] = updater = createPropUpdater( + dom, + prop, + signal, + renderedProps + ); } else { updater._update(signal, renderedProps); } @@ -351,6 +352,12 @@ export function useSignalEffect(cb: () => void | (() => void)) { }, []); } +export function useWatcher(value: T) { + const watcher = useSignal(value); + watcher.value = value; + return watcher as ReadonlySignal; +} + /** * @todo Determine which Reactive implementation we'll be using. * @internal diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts index 436ac029a..a62b373bc 100644 --- a/packages/preact/src/internal.d.ts +++ b/packages/preact/src/internal.d.ts @@ -31,7 +31,7 @@ export interface VNode

extends preact.VNode

{ /** The DOM node for this VNode */ __e?: Element | Text; /** Props that had Signal values before diffing (used after diffing to subscribe) */ - __np?: Record | null; + __np?: Record | null; } export const enum OptionsTypes { diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index dd98e3a74..1bac98a7d 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -3,6 +3,7 @@ import { computed, useComputed, useSignalEffect, + useWatcher, Signal, } from "@preact/signals"; import { createElement, createRef, render } from "preact"; @@ -353,6 +354,111 @@ describe("@preact/signals", () => { }); }); + describe("use watcher hook", () => { + it("should set the initial value of the checked property", () => { + function App({ checked = false }) { + const $checked = useWatcher(checked); + // @ts-ignore + return ; + } + + render(, scratch); + expect(scratch.firstChild).to.have.property("checked", true); + }); + + it("should update the checked property on change", () => { + function App({ checked = false }) { + const $checked = useWatcher(checked); + // @ts-ignore + return ; + } + + render(, scratch); + expect(scratch.firstChild).to.have.property("checked", true); + + render(, scratch); + expect(scratch.firstChild).to.have.property("checked", false); + }); + + it("should update computed signal", () => { + function App({ value = 0 }) { + const $value = useWatcher(value); + const timesTwo = useComputed(() => $value.value * 2); + return

{timesTwo}

; + } + + render(, scratch); + expect(scratch.textContent).to.equal("2"); + + render(, scratch); + expect(scratch.textContent).to.equal("8"); + }); + + it("should not cascade rerenders", () => { + const spy = sinon.spy(); + function App({ value = 0 }) { + const $value = useWatcher(value); + const timesTwo = useComputed(() => $value.value * 2); + spy(); + return

{timesTwo.value}

; + } + + render(, scratch); + render(, scratch); + + expect(spy).to.be.calledTwice; + }); + + it("should update all silblings", () => { + function Test({ value }: { value: number }) { + const $value = useWatcher(value); + return {$value.value}; + } + + function App({ value = 0 }) { + return ( +
+ + +
+ ); + } + + const firstChild = () => scratch.firstChild?.firstChild; + + render(, scratch); + expect(firstChild()?.textContent).to.be.equal("1"); + expect(firstChild()?.nextSibling?.textContent).to.be.equal("1"); + + render(, scratch); + expect(firstChild()?.textContent).to.be.equal("4"); + expect(firstChild()?.nextSibling?.textContent).to.be.equal("4"); + }); + + it("should not rerender siblings", () => { + const spy = sinon.spy(); + function Test({ value }: { value: number }) { + const $value = useWatcher(value); + spy(); + return {$value.value}; + } + + function App({ value = 0 }) { + return ( +
+ + +
+ ); + } + + render(, scratch); + render(, scratch); + + expect(spy).to.be.callCount(4); + }); + }); + describe("useSignalEffect()", () => { it("should be invoked after commit", async () => { const ref = createRef(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 570656d71..8bd6cb152 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -100,6 +100,8 @@ function WrapWithProxy(Component: FunctionComponent) { return WrappedComponent; } +let renderDepth = 0; + /** * A redux-like store whose store value is a positive 32bit integer (a 'version'). * @@ -118,10 +120,24 @@ function createEffectStore() { let version = 0; let onChangeNotifyReact: (() => void) | undefined; - let unsubscribe = effect(function (this: Effect) { + const unsubscribe = effect(function (this: Effect) { updater = this; }); + + const oldStart = updater._start; + + updater._start = function () { + renderDepth++; + const finish = oldStart.call(updater); + return function () { + finish(); + renderDepth--; + }; + }; + updater._callback = function () { + // only notify React of state changes when not rendering in a component + if (renderDepth > 0) return; version = (version + 1) | 0; if (onChangeNotifyReact) onChangeNotifyReact(); }; @@ -235,3 +251,9 @@ export function useSignalEffect(cb: () => void | (() => void)) { return effect(() => callback.current()); }, Empty); } + +export function useWatcher(value: T) { + const watcher = useSignal(value); + watcher.value = value; + return watcher as ReadonlySignal; +} diff --git a/packages/react/test/index.test.tsx b/packages/react/test/index.test.tsx index a4c7f98ef..8d3e1736d 100644 --- a/packages/react/test/index.test.tsx +++ b/packages/react/test/index.test.tsx @@ -1,7 +1,7 @@ // @ts-ignore-next-line globalThis.IS_REACT_ACT_ENVIRONMENT = true; -import { signal, useComputed, useSignalEffect } from "@preact/signals-react"; +import { signal, useComputed, useSignalEffect, useWatcher } from "@preact/signals-react"; import { createElement, useMemo, memo, StrictMode, createRef } from "react"; import { createRoot, Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; @@ -223,6 +223,129 @@ describe("@preact/signals-react", () => { }); }); + describe("use watcher hook", () => { + it("should set the initial value of the checked property", () => { + function App({ value = 0 }) { + const $value = useWatcher(value); + return {$value}; + } + + render(); + expect(scratch.textContent).to.equal("1"); + }); + + it("should update the checked property on change", () => { + function App({ value = 0 }) { + const $value = useWatcher(value); + return {$value}; + } + + render(); + expect(scratch.textContent).to.equal("1"); + + render(); + expect(scratch.textContent).to.equal("4"); + }); + + it("should update computed signal", () => { + function App({ value = 0 }) { + const $value = useWatcher(value); + const timesTwo = useComputed(() => $value.value * 2); + return {timesTwo}; + } + + render(); + expect(scratch.textContent).to.equal("2"); + + render(); + expect(scratch.textContent).to.equal("8"); + }); + + it("should consistently rerender in strict mode", () => { + function Test({ value }: { value: number }) { + const $value = useWatcher(value); + return {$value}; + } + + function App({ value = 0 }) { + return ( + + + + ); + } + + for (let i = 0; i < 3; ++i) { + render(); + expect(scratch.textContent).is.equal(`${i}`); + } + }); + + it("should not cascade rerenders", () => { + const spy = sinon.spy(); + function App({ value = 0 }) { + const $value = useWatcher(value); + const timesTwo = useComputed(() => $value.value * 2); + spy(); + return

{timesTwo.value}

; + } + + render(); + render(); + + expect(spy).to.be.calledTwice; + }); + + it("should update all silblings", () => { + function Test({ value }: { value: number }) { + const $value = useWatcher(value); + return {$value.value}; + } + + function App({ value = 0 }) { + return ( +
+ + +
+ ); + } + + const firstChild = () => scratch.firstChild?.firstChild; + + render(); + expect(firstChild()?.textContent).to.be.equal("1"); + expect(firstChild()?.nextSibling?.textContent).to.be.equal("1"); + + render(); + expect(firstChild()?.textContent).to.be.equal("4"); + expect(firstChild()?.nextSibling?.textContent).to.be.equal("4"); + }); + + it("should not rerender siblings", () => { + const spy = sinon.spy(); + function Test({ value }: { value: number }) { + const $value = useWatcher(value); + spy(); + return {$value.value}; + } + + function App({ value = 0 }) { + return ( +
+ + +
+ ); + } + + render(); + render(); + + expect(spy).to.be.callCount(4); + }); + }); + describe("useSignalEffect()", () => { it("should be invoked after commit", async () => { const ref = createRef();