Skip to content

Commit

Permalink
Merge pull request #153 from preactjs/preact-dom-bindings
Browse files Browse the repository at this point in the history
[preact] Optimize performance of prop bindings
  • Loading branch information
marvinhagemeister authored Sep 15, 2022
2 parents a35de94 + 0da9ce3 commit b70bc48
Show file tree
Hide file tree
Showing 7 changed files with 280 additions and 61 deletions.
5 changes: 5 additions & 0 deletions .changeset/unlucky-rocks-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@preact/signals": patch
---

Optimize the performance of prop bindings in Preact
140 changes: 140 additions & 0 deletions docs/demos/animation/index.tsx
Original file line number Diff line number Diff line change
@@ -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] = (
<Circle i={i} x={x} y={y} big={big} max={max} counter={counter} />
);
}

return (
<div class="animation">
<Circle i={0} x={x} y={y} big={big} max={max} counter={counter} label />
{circles}
</div>
);
}

interface CircleProps {
i: number;
x: Signal<number>;
y: Signal<number>;
big: Signal<boolean>;
max: Signal<number>;
counter: Signal<number>;
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 <CircleInner {...{ label, x, y, offsetX, offsetY, hue, big }} />;
// }
// 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 (
<div class={cl} style={style}>
{label && <Label x={x} y={y} />}
</div>
);
}

function Label({ x, y }: { x: Signal<number>; y: Signal<number> }) {
return (
<span class="label">
{x},{y}
</span>
);
}
49 changes: 49 additions & 0 deletions docs/demos/animation/style.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions docs/demos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const demos = {
GlobalCounter,
DuelingCounters,
Nesting: lazy(() => import("./nesting")),
Animation: lazy(() => import("./animation")),
};

function Demos() {
Expand Down
15 changes: 12 additions & 3 deletions docs/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 70 additions & 57 deletions packages/preact/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, any> | 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);
Expand Down Expand Up @@ -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<string, any> = { __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);
Expand Down
Loading

0 comments on commit b70bc48

Please sign in to comment.