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 {