Skip to content
34 changes: 32 additions & 2 deletions packages/preact/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -172,13 +175,17 @@ 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<string, any> | undefined;

let props = vnode.props;
for (let i in props) {
if (i === "children") continue;

let value = props[i];
if (value && value.type === JSXBind) {
value = oldSignalProps?.[i] ?? computed(value.cb);
}
if (value instanceof Signal) {
if (!signalProps) vnode.__np = signalProps = {};
signalProps[i] = value;
Expand Down Expand Up @@ -465,6 +472,29 @@ 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<T>(cb: () => T): T {
return Object.defineProperties({ cb }, jsxBindPrototype) as any;
}

/**
* @todo Determine which Reactive implementation we'll be using.
* @internal
Expand Down
100 changes: 100 additions & 0 deletions packages/preact/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
jsxBind,
computed,
useComputed,
useSignalEffect,
Expand Down Expand Up @@ -724,6 +725,105 @@ 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 (
<div data-value={jsxBind(() => count.value * double.value)}></div>
);
}

render(<App />, scratch);
expect(spy).to.have.been.calledOnce;
expect(scratch.innerHTML).to.equal('<div data-value="0"></div>');

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('<div data-value="10"></div>');

act(() => {
double.value = 3;
});

expect(spy).to.have.been.calledOnce;
expect(scratch.innerHTML).to.equal('<div data-value="15"></div>');
});

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 (
<div>{jsxBind(() => `${firstName.value} ${lastName.value}`)}</div>
);
}

render(<App />, scratch);
expect(spy).to.have.been.calledOnce;
expect(scratch.innerHTML).to.equal("<div>John Doe</div>");

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("<div>Jane Doe</div>");
});

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 (
<button disabled={jsxBind(() => !enabled.value)}>
{jsxBind(boundSpy)}
</button>
);
}

render(<App />, scratch);
expect(renderSpy).to.have.been.calledOnce;
expect(boundSpy).to.have.been.called;
expect(scratch.innerHTML).to.equal("<button>0</button>");

act(() => {
count.value = 5;
});

expect(renderSpy).to.have.been.calledOnce;
expect(boundSpy).to.have.been.calledTwice;
expect(scratch.innerHTML).to.equal("<button>5</button>");

act(() => {
enabled.value = false;
});

expect(renderSpy).to.have.been.calledOnce;
expect(scratch.innerHTML).to.equal(
`<button disabled="">disabled</button>`
);
});
});

describe("hooks mixed with signals", () => {
it("signals should not stop context from propagating", () => {
const ctx = createContext({ test: "should-not-exist" });
Expand Down