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. diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts index 466b957ae..4bd249c83 100644 --- a/packages/preact/src/index.ts +++ b/packages/preact/src/index.ts @@ -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 @@ -105,6 +108,10 @@ function SignalValue(this: AugmentedComponent, { data }: { data: Signal }) { const wrappedSignal = computed(() => { let s = currentSignal.value.value; + // This is possibly an inline computed from jsxBind + if (typeof s === "function") { + s = s(); + } return s === 0 ? 0 : s === true ? "" : s || ""; }); @@ -172,6 +179,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 +187,9 @@ hook(OptionsTypes.DIFF, (old, vnode) => { if (i === "children") continue; let value = props[i]; + if (value && typeof value === "object" && value.__proto__ === jsxBind) { + value = oldSignalProps?.[i] || computed(value.value); + } if (value instanceof Signal) { if (!signalProps) vnode.__np = signalProps = {}; signalProps[i] = value; @@ -465,6 +476,17 @@ export function useSignalEffect( }, []); } +/** + * 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 { value: cb, __proto__: jsxBind } as any; +} + +Object.setPrototypeOf(jsxBind, Signal.prototype); + /** * @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..3157a5b84 100644 --- a/packages/preact/test/index.test.tsx +++ b/packages/preact/test/index.test.tsx @@ -1,4 +1,5 @@ import { + jsxBind, computed, useComputed, useSignalEffect, @@ -724,6 +725,159 @@ describe("@preact/signals", () => { }); }); + describe("jsxBind", () => { + 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 ( +
{jsxBind(() => `${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( + `` + ); + }); + + 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", () => { it("signals should not stop context from propagating", () => { const ctx = createContext({ test: "should-not-exist" });