diff --git a/.changeset/unlucky-rocks-return.md b/.changeset/unlucky-rocks-return.md
new file mode 100644
index 000000000..d6b9ef847
--- /dev/null
+++ b/.changeset/unlucky-rocks-return.md
@@ -0,0 +1,5 @@
+---
+"@preact/signals": patch
+---
+
+Optimize the performance of prop bindings in Preact
diff --git a/docs/demos/animation/index.tsx b/docs/demos/animation/index.tsx
new file mode 100644
index 000000000..1bbea6a14
--- /dev/null
+++ b/docs/demos/animation/index.tsx
@@ -0,0 +1,140 @@
+import { useEffect } from "preact/hooks";
+import { useSignal, useComputed, Signal } from "@preact/signals";
+import "./style.css";
+import { setFlashingEnabled } from "../render-flasher";
+
+const COUNT = 200;
+const LOOPS = 6;
+
+export default function Animation() {
+ const x = useSignal(0);
+ const y = useSignal(0);
+ const big = useSignal(false);
+ const counter = useSignal(0);
+
+ useEffect(() => {
+ let touch = navigator.maxTouchPoints > 1;
+
+ // set mouse position state on move:
+ function move(e: MouseEvent | TouchEvent) {
+ const pointer = "touches" in e ? e.touches[0] : e;
+ x.value = pointer.clientX;
+ y.value = pointer.clientY - 52;
+ }
+ // holding the mouse down enables big mode:
+ function setBig(e: Event) {
+ big.value = true;
+ e.preventDefault();
+ }
+ function notBig() {
+ big.value = false;
+ }
+ addEventListener(touch ? "touchmove" : "mousemove", move);
+ addEventListener(touch ? "touchstart" : "mousedown", setBig);
+ addEventListener(touch ? "touchend" : "mouseup", notBig);
+
+ let running = true;
+ function tick() {
+ if (running === false) return;
+ counter.value++;
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+
+ setFlashingEnabled(false);
+ setTimeout(() => setFlashingEnabled(false), 150);
+
+ return () => {
+ running = false;
+ setFlashingEnabled(true);
+ removeEventListener(touch ? "touchmove" : "mousemove", move);
+ removeEventListener(touch ? "touchstart" : "mousedown", setBig);
+ removeEventListener(touch ? "touchend" : "mouseup", notBig);
+ };
+ }, []);
+
+ const max = useComputed(() => {
+ return (
+ COUNT +
+ Math.round(Math.sin((counter.value / 90) * 2 * Math.PI) * COUNT * 0.5)
+ );
+ });
+
+ let circles = [];
+
+ // the advantage of JSX is that you can use the entirety of JS to "template":
+ for (let i = max.value; i--; ) {
+ circles[i] = (
+
+ );
+ }
+
+ return (
+
+
+ {circles}
+
+ );
+}
+
+interface CircleProps {
+ i: number;
+ x: Signal;
+ y: Signal;
+ big: Signal;
+ max: Signal;
+ counter: Signal;
+ label?: boolean;
+}
+
+/** Represents a single coloured dot. */
+function Circle({ label, i, x, y, big, max, counter }: CircleProps) {
+ const hue = useComputed(() => {
+ let f = (i / max.value) * LOOPS;
+ return (f * 255 + counter.value * 10) % 255;
+ });
+
+ const offsetX = useComputed(() => {
+ let f = (i / max.value) * LOOPS;
+ let θ = f * 2 * Math.PI;
+ return Math.sin(θ) * (20 + i * 2);
+ });
+
+ const offsetY = useComputed(() => {
+ let f = (i / max.value) * LOOPS;
+ let θ = f * 2 * Math.PI;
+ return Math.cos(θ) * (20 + i * 2);
+ });
+
+ // For testing nested "computeds-only" components (for GC):
+ // return ;
+ // }
+ // function CircleInner({ label, x, y, offsetX, offsetY, hue, big }) {
+
+ const style = useComputed(() => {
+ let left = (x.value + offsetX.value) | 0;
+ let top = (y.value + offsetY.value) | 0;
+ return `left:${left}px; top:${top}px; border-color:hsl(${hue},100%,50%);`;
+ });
+
+ const cl = useComputed(() => {
+ let cl = "circle";
+ if (label) cl += " label";
+ if (big.value) cl += " big";
+ return cl;
+ });
+
+ return (
+
+ {label && }
+
+ );
+}
+
+function Label({ x, y }: { x: Signal; y: Signal }) {
+ return (
+
+ {x},{y}
+
+ );
+}
diff --git a/docs/demos/animation/style.css b/docs/demos/animation/style.css
new file mode 100644
index 000000000..22db4db08
--- /dev/null
+++ b/docs/demos/animation/style.css
@@ -0,0 +1,49 @@
+.animation {
+ position: absolute;
+ left: 0;
+ top: 52px;
+ bottom: 0;
+ width: 100%;
+ background: #222;
+ color: #888;
+ text-rendering: optimizeSpeed;
+ touch-action: none;
+ overflow: hidden;
+}
+
+.circle {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 8px;
+ height: 8px;
+ margin: -5px 0 0 -5px;
+ border: 2px solid #f00;
+ border-radius: 50%;
+ transform-origin: 50% 50%;
+ transition: all 250ms ease;
+ transition-property: width, height, margin;
+ pointer-events: none;
+ overflow: hidden;
+ font-size: 9px;
+ line-height: 25px;
+ text-indent: 15px;
+ white-space: nowrap;
+
+ &.label {
+ overflow: visible;
+ }
+
+ &.big {
+ width: 24px;
+ height: 24px;
+ margin: -13px 0 0 -13px;
+ }
+
+ & > .label {
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: 10;
+ }
+}
diff --git a/docs/demos/index.tsx b/docs/demos/index.tsx
index 28aa34d21..cbbf08bb7 100644
--- a/docs/demos/index.tsx
+++ b/docs/demos/index.tsx
@@ -12,6 +12,7 @@ const demos = {
GlobalCounter,
DuelingCounters,
Nesting: lazy(() => import("./nesting")),
+ Animation: lazy(() => import("./animation")),
};
function Demos() {
diff --git a/docs/vite.config.ts b/docs/vite.config.ts
index a4982bb34..08a0ebad7 100644
--- a/docs/vite.config.ts
+++ b/docs/vite.config.ts
@@ -21,12 +21,21 @@ function packages(prod: boolean) {
export default defineConfig(env => ({
plugins: [
- preact({
- exclude: /\breact/,
- }),
+ process.env.DEBUG
+ ? preact({
+ exclude: /\breact/,
+ })
+ : null,
multiSpa(["index.html", "demos/**/*.html"]),
unsetPreactAliases(),
],
+ esbuild: {
+ jsx: "automatic",
+ jsxImportSource: "preact",
+ },
+ optimizeDeps: {
+ include: ["preact/jsx-runtime", "preact/jsx-dev-runtime"],
+ },
build: {
polyfillModulePreload: false,
cssCodeSplit: false,
diff --git a/packages/preact/src/index.ts b/packages/preact/src/index.ts
index 4185fb53e..47ee32ccc 100644
--- a/packages/preact/src/index.ts
+++ b/packages/preact/src/index.ts
@@ -53,36 +53,6 @@ function createUpdater(updater: () => void) {
return s;
}
-// Get a (cached) Signal property updater for an element VNode
-function getElementUpdater(vnode: VNode) {
- let updater = updaterForComponent.get(vnode) as ElementUpdater;
- if (!updater) {
- let signalProps: Array<{ _key: string; _signal: Signal }> = [];
- updater = createUpdater(() => {
- let dom = vnode.__e as Element;
-
- for (let i = 0; i < signalProps.length; i++) {
- let { _key: prop, _signal: signal } = signalProps[i];
- let value = signal._value;
- if (!dom) return;
- if (prop in dom) {
- // @ts-ignore-next-line silly
- dom[prop] = value;
- } else if (value) {
- dom.setAttribute(prop, value);
- } else {
- dom.removeAttribute(prop);
- }
- }
- }) as ElementUpdater;
- updater._props = signalProps;
- updaterForComponent.set(vnode, updater);
- } else {
- updater._props.length = 0;
- }
- return updater;
-}
-
/** @todo This may be needed for complex prop value detection. */
// function isSignalValue(value: any): value is Signal {
// if (typeof value !== "object" || value == null) return false;
@@ -148,34 +118,19 @@ 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 orig = vnode.__o || vnode;
- let props = vnode.props;
- let updater;
+ let signalProps: Record | undefined;
+ let props = vnode.props;
for (let i in props) {
- let value = props[i];
if (i === "children") continue;
+ let value = props[i];
if (value instanceof Signal) {
- // first Signal prop triggers creation/cleanup of the updater:
- if (!updater) updater = getElementUpdater(vnode);
- // track which props are Signals for precise updates:
- updater._props.push({ _key: i, _signal: value });
- let newUpdater = updater._updater;
- if (value._updater) {
- let oldUpdater = value._updater;
- value._updater = () => {
- newUpdater();
- oldUpdater();
- };
- } else {
- value._updater = newUpdater;
- }
+ if (!signalProps) vnode.__np = signalProps = {};
+ signalProps[i] = value;
props[i] = value.peek();
}
}
-
- setCurrentUpdater(updater);
}
old(vnode);
@@ -215,19 +170,77 @@ hook(OptionsTypes.CATCH_ERROR, (old, error, vnode, oldVNode) => {
hook(OptionsTypes.DIFFED, (old, vnode) => {
setCurrentUpdater();
currentComponent = undefined;
+
+ let dom: Element;
+ let updater: ElementUpdater;
+
+ // vnode._dom is undefined during string rendering,
+ // so we use this to skip prop subscriptions during SSR.
+ if (typeof vnode.type === "string" && (dom = vnode.__e as Element)) {
+ let props = vnode.__np;
+ if (props) {
+ // @ts-ignore-next
+ updater = dom._updater;
+ if (!updater) {
+ updater = createElementUpdater(dom);
+ // @ts-ignore-next
+ dom._updater = updater;
+ }
+ updater!._props = props;
+ setCurrentUpdater(updater);
+ // @ts-ignore-next we're adding an argument here
+ updater._updater(true);
+ }
+ }
old(vnode);
});
+// per-element updater for 1+ signal bindings
+function createElementUpdater(dom: Element) {
+ const cache: Record = { __proto__: null };
+ const updater = createUpdater((skip?: boolean) => {
+ const props = updater._props;
+ for (let prop in props) {
+ if (prop === "children") continue;
+ let signal = props[prop];
+ if (signal instanceof Signal) {
+ let value = signal.value;
+ let cached = cache[prop];
+ cache[prop] = value;
+ if (skip === true || cached === value) {
+ // this is just a subscribe run, not an update
+ } else if (prop in dom) {
+ // @ts-ignore-next-line silly
+ dom[prop] = value;
+ } else if (value) {
+ dom.setAttribute(prop, value);
+ } else {
+ dom.removeAttribute(prop);
+ }
+ }
+ }
+ }) as ElementUpdater;
+ return updater;
+}
+
/** Unsubscribe from Signals when unmounting components/vnodes */
hook(OptionsTypes.UNMOUNT, (old, vnode: VNode) => {
- let thing = vnode.__c || vnode;
- const updater = updaterForComponent.get(thing);
+ let component = vnode.__c;
+ const updater = component && updaterForComponent.get(component);
if (updater) {
- updaterForComponent.delete(thing);
- const signals = updater._deps;
- if (signals) {
- signals.forEach(signal => signal._subs.delete(updater));
- signals.clear();
+ updaterForComponent.delete(component);
+ updater._setCurrent()(true, true);
+ }
+
+ if (typeof vnode.type === "string") {
+ const dom = vnode.__e as Element;
+
+ // @ts-ignore-next
+ const updater = dom._updater;
+ if (updater) {
+ updater._setCurrent()(true, true);
+ // @ts-ignore-next
+ dom._updater = null;
}
}
old(vnode);
diff --git a/packages/preact/src/internal.d.ts b/packages/preact/src/internal.d.ts
index 8ddf8c37c..ce8c688a3 100644
--- a/packages/preact/src/internal.d.ts
+++ b/packages/preact/src/internal.d.ts
@@ -8,6 +8,8 @@ export interface VNode extends preact.VNode
{
__?: 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;
}
export interface ComponentType extends Component {
@@ -18,7 +20,7 @@ export interface ComponentType extends Component {
export type Updater = Signal;
export interface ElementUpdater extends Updater {
- _props: Array<{ _key: string, _signal: Signal }>;
+ _props: Record;
}
export const enum OptionsTypes {