From 04a49abab321099c2961fe0c74d5bbee9aca2fd9 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:24:05 -0400 Subject: [PATCH 01/10] feat: add `bind()` function for inline computeds --- packages/preact/src/index.ts | 34 +++++++++- packages/preact/test/index.test.tsx | 96 +++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 466b957ae..c5ecb45f2 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -1,4 +1,4 @@ -import { options, Component, isValidElement, Fragment } from "preact"; +import { options, Component, isValidElement, Fragment, h } from "preact"; import { useRef, useMemo, useEffect } from "preact/hooks"; import { signal, @@ -84,7 +84,10 @@ function createUpdater(update: () => void) { * A wrapper component that renders a Signal directly as a Text node. * @todo: in Preact 11, just decorate Signal with `type:null` */ -function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) { +function SignalValue( + this: AugmentedComponent, + { data }: { data: ReadonlySignal } +) { // hasComputeds.add(this); // Store the props.data signal in another signal so that @@ -172,6 +175,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") { + const oldSignalProps = vnode.__np; let signalProps: Record | undefined; let props = vnode.props; @@ -179,6 +183,9 @@ hook(OptionsTypes.DIFF, (old, vnode) => { if (i === "children") continue; let value = props[i]; + if (value && value.type === Bind) { + value = oldSignalProps?.[i] ?? computed(value.cb); + } if (value instanceof Signal) { if (!signalProps) vnode.__np = signalProps = {}; signalProps[i] = value; @@ -465,6 +472,29 @@ export function useSignalEffect( }, []); } +function Bind({ cb }: { cb: () => unknown }) { + return h(SignalValue, { + data: useComputed(cb), + }); +} + +const bindPrototype = Object.getOwnPropertyDescriptors({ + constructor: undefined, + type: Bind, + get props() { + return this; + }, +}); + +/** + * Bind the given callback to a JSX attribute or JSX child. This allows for "inline computed" + * signals that derive their value from other signals. Like with `useComputed`, any non-signal + * values used in the callback are captured at the time of binding and won't change after that. + */ +export function bind(cb: () => T): T { + return Object.defineProperties({ cb }, bindPrototype) as any; +} + /** * @todo Determine which Reactive implementation we'll be using. * @internal diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index 7d3ebe95b..7b08451e3 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -1,4 +1,5 @@ import { + bind, computed, useComputed, useSignalEffect, @@ -724,6 +725,101 @@ describe("@preact/signals", () => { }); }); + describe("inline computed bindings", () => { + it("should bind a callback to a JSX attribute", async () => { + const count = signal(0); + const double = signal(2); + const spy = sinon.spy(); + + function App() { + spy(); + return
count.value * double.value)}>
; + } + + render(, scratch); + expect(spy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal('
'); + + act(() => { + count.value = 5; + }); + + // Component should not re-render when only the bound value changes + expect(spy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal('
'); + + act(() => { + double.value = 3; + }); + + expect(spy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal('
'); + }); + + it("should bind a callback to a JSX child", async () => { + const firstName = signal("John"); + const lastName = signal("Doe"); + const spy = sinon.spy(); + + function App() { + spy(); + return
{bind(() => `${firstName.value} ${lastName.value}`)}
; + } + + render(, scratch); + expect(spy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
John Doe
"); + + act(() => { + firstName.value = "Jane"; + }); + + // Component should not re-render when only the bound value changes + expect(spy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Jane Doe
"); + }); + + it("should update bound values without re-rendering the component", async () => { + const count = signal(0); + const enabled = signal(true); + const renderSpy = sinon.spy(); + const boundSpy = sinon.spy(() => + enabled.value ? count.value : "disabled" + ); + + function App() { + renderSpy(); + return ( + + ); + } + + render(, scratch); + expect(renderSpy).to.have.been.calledOnce; + expect(boundSpy).to.have.been.called; + expect(scratch.innerHTML).to.equal(""); + + act(() => { + count.value = 5; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(boundSpy).to.have.been.calledTwice; + expect(scratch.innerHTML).to.equal(""); + + act(() => { + enabled.value = false; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal( + `` + ); + }); + }); + describe("hooks mixed with signals", () => { it("signals should not stop context from propagating", () => { const ctx = createContext({ test: "should-not-exist" }); From eab99b182985e10a237c53c8a900dbd78e5709d3 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 11 Jul 2025 18:25:21 -0400 Subject: [PATCH 02/10] chore: rename to jsxBind --- packages/preact/src/index.ts | 12 ++++++------ packages/preact/test/index.test.tsx | 16 ++++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index c5ecb45f2..203602bc6 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -183,7 +183,7 @@ hook(OptionsTypes.DIFF, (old, vnode) => { if (i === "children") continue; let value = props[i]; - if (value && value.type === Bind) { + if (value && value.type === JSXBind) { value = oldSignalProps?.[i] ?? computed(value.cb); } if (value instanceof Signal) { @@ -472,15 +472,15 @@ export function useSignalEffect( }, []); } -function Bind({ cb }: { cb: () => unknown }) { +function JSXBind({ cb }: { cb: () => unknown }) { return h(SignalValue, { data: useComputed(cb), }); } -const bindPrototype = Object.getOwnPropertyDescriptors({ +const jsxBindPrototype = Object.getOwnPropertyDescriptors({ constructor: undefined, - type: Bind, + type: JSXBind, get props() { return this; }, @@ -491,8 +491,8 @@ const bindPrototype = Object.getOwnPropertyDescriptors({ * signals that derive their value from other signals. Like with `useComputed`, any non-signal * values used in the callback are captured at the time of binding and won't change after that. */ -export function bind(cb: () => T): T { - return Object.defineProperties({ cb }, bindPrototype) as any; +export function jsxBind(cb: () => T): T { + return Object.defineProperties({ cb }, jsxBindPrototype) as any; } /** diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index 7b08451e3..3736dbc92 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -1,5 +1,5 @@ import { - bind, + jsxBind, computed, useComputed, useSignalEffect, @@ -725,7 +725,7 @@ describe("@preact/signals", () => { }); }); - describe("inline computed bindings", () => { + describe("jsxBind", () => { it("should bind a callback to a JSX attribute", async () => { const count = signal(0); const double = signal(2); @@ -733,7 +733,9 @@ describe("@preact/signals", () => { function App() { spy(); - return
count.value * double.value)}>
; + return ( +
count.value * double.value)}>
+ ); } render(, scratch); @@ -763,7 +765,9 @@ describe("@preact/signals", () => { function App() { spy(); - return
{bind(() => `${firstName.value} ${lastName.value}`)}
; + return ( +
{jsxBind(() => `${firstName.value} ${lastName.value}`)}
+ ); } render(, scratch); @@ -790,8 +794,8 @@ describe("@preact/signals", () => { function App() { renderSpy(); return ( - ); } From c949987ed28bcec98f9488dac9b2b0817553d7c8 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 03:57:43 -0400 Subject: [PATCH 03/10] chore(refactor): remove `JSXBind` component and `jsxBindPrototype` object --- packages/preact/src/index.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 203602bc6..c755226bf 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -108,6 +108,9 @@ function SignalValue( const wrappedSignal = computed(() => { let s = currentSignal.value.value; + if (typeof s === "function") { + s = s(); + } return s === 0 ? 0 : s === true ? "" : s || ""; }); @@ -183,8 +186,8 @@ hook(OptionsTypes.DIFF, (old, vnode) => { if (i === "children") continue; let value = props[i]; - if (value && value.type === JSXBind) { - value = oldSignalProps?.[i] ?? computed(value.cb); + if (value && typeof value === "object" && value.__proto__ === jsxBind) { + value = oldSignalProps?.[i] ?? computed(value.value); } if (value instanceof Signal) { if (!signalProps) vnode.__np = signalProps = {}; @@ -472,29 +475,17 @@ export function useSignalEffect( }, []); } -function JSXBind({ cb }: { cb: () => unknown }) { - return h(SignalValue, { - data: useComputed(cb), - }); -} - -const jsxBindPrototype = Object.getOwnPropertyDescriptors({ - constructor: undefined, - type: JSXBind, - get props() { - return this; - }, -}); - /** * Bind the given callback to a JSX attribute or JSX child. This allows for "inline computed" * signals that derive their value from other signals. Like with `useComputed`, any non-signal * values used in the callback are captured at the time of binding and won't change after that. */ export function jsxBind(cb: () => T): T { - return Object.defineProperties({ cb }, jsxBindPrototype) as any; + return { value: cb, __proto__: jsxBind } as any; } +Object.setPrototypeOf(jsxBind, Signal.prototype); + /** * @todo Determine which Reactive implementation we'll be using. * @internal From 27706a1ee0de5827dcc731d231b3a49fab4544cc Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 04:01:21 -0400 Subject: [PATCH 04/10] chore: remove unused import --- packages/preact/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index c755226bf..f6b26a582 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -1,4 +1,4 @@ -import { options, Component, isValidElement, Fragment, h } from "preact"; +import { options, Component, isValidElement, Fragment } from "preact"; import { useRef, useMemo, useEffect } from "preact/hooks"; import { signal, From 66d676ac2ed2103f0ab5b48297d1a7686d772802 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:02:56 -0400 Subject: [PATCH 05/10] use || operator Co-authored-by: Jovi De Croock --- packages/preact/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index f6b26a582..3bdd3814e 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -187,7 +187,7 @@ hook(OptionsTypes.DIFF, (old, vnode) => { let value = props[i]; if (value && typeof value === "object" && value.__proto__ === jsxBind) { - value = oldSignalProps?.[i] ?? computed(value.value); + value = oldSignalProps?.[i] || computed(value.value); } if (value instanceof Signal) { if (!signalProps) vnode.__np = signalProps = {}; From d58a8aa1f7a7bb045148b98668090a2d4db7245b Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:03:33 -0400 Subject: [PATCH 06/10] Update packages/preact/src/index.ts Co-authored-by: Jovi De Croock --- packages/preact/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 3bdd3814e..b629bc401 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -108,6 +108,7 @@ function SignalValue( const wrappedSignal = computed(() => { let s = currentSignal.value.value; + // This is possibly an inline computed from jsxBind if (typeof s === "function") { s = s(); } From 4d14f3d9b8684057abfe17d21d46ca15bf24ad3c Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:03:54 -0400 Subject: [PATCH 07/10] chore: fix indent size --- packages/preact/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index b629bc401..4bd249c83 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -108,7 +108,7 @@ function SignalValue( const wrappedSignal = computed(() => { let s = currentSignal.value.value; - // This is possibly an inline computed from jsxBind + // This is possibly an inline computed from jsxBind if (typeof s === "function") { s = s(); } From a4cb42aacabda147077b27d82161f4d8c457717b Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:15:35 -0400 Subject: [PATCH 08/10] =?UTF-8?q?chore(test):=20check=20that=20text=20?= =?UTF-8?q?=E2=87=84=20element=20toggling=20works=20as=20expected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/preact/test/index.test.tsx | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/preact/test/index.test.tsx b/packages/preact/test/index.test.tsx index 3736dbc92..3157a5b84 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -822,6 +822,60 @@ describe("@preact/signals", () => { `` ); }); + + it("can toggle between JSX text and JSX element", async () => { + const bold = signal(false); + const label = signal("Hello"); + const renderSpy = sinon.spy(); + + function App() { + renderSpy(); + return ( +
+ {jsxBind(() => + bold.value ? {label.value} : label.value + )} +
+ ); + } + + render(, scratch); + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Hello
"); + + // Text-to-text update. + act(() => { + label.value = "Bonjour"; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Bonjour
"); + + // Text-to-element update. + act(() => { + bold.value = true; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Bonjour
"); + + // Element-to-element update. + act(() => { + label.value = "Pryvit"; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Pryvit
"); + + // Element-to-text update. + act(() => { + label.value = "Hola"; + bold.value = false; + }); + + expect(renderSpy).to.have.been.calledOnce; + expect(scratch.innerHTML).to.equal("
Hola
"); + }); }); describe("hooks mixed with signals", () => { From 4604dd9baa1f8b8912ef9e6eee72c0b7207732f0 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:18:22 -0400 Subject: [PATCH 09/10] chore: add changset --- .changeset/tender-waves-do.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tender-waves-do.md diff --git a/.changeset/tender-waves-do.md b/.changeset/tender-waves-do.md new file mode 100644 index 000000000..b479a1195 --- /dev/null +++ b/.changeset/tender-waves-do.md @@ -0,0 +1,5 @@ +--- +"@preact/signals": minor +--- + +Introduce the `jsxBind` function for inlined `computed` declarations as JSX attributes or JSX children. From 00ad63356d85830242f2ce33bbfed5a5225bc468 Mon Sep 17 00:00:00 2001 From: Alec Larson <1925840+aleclarson@users.noreply.github.com> Date: Fri, 25 Jul 2025 03:52:07 -0400 Subject: [PATCH 10/10] fix: use Object.getPrototypeOf --- packages/preact/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 4bd249c83..c377fe2b8 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -187,7 +187,11 @@ hook(OptionsTypes.DIFF, (old, vnode) => { if (i === "children") continue; let value = props[i]; - if (value && typeof value === "object" && value.__proto__ === jsxBind) { + if ( + value && + typeof value === "object" && + Object.getPrototypeOf(value) === jsxBind + ) { value = oldSignalProps?.[i] || computed(value.value); } if (value instanceof Signal) {