Skip to content

Commit

Permalink
Merge pull request #3708 from preactjs/todo-benchmark
Browse files Browse the repository at this point in the history
  • Loading branch information
marvinhagemeister committed Sep 2, 2022
2 parents e70238f + b749474 commit 1427d58
Show file tree
Hide file tree
Showing 8 changed files with 347 additions and 1 deletion.
28 changes: 28 additions & 0 deletions .github/workflows/benchmarks.yml
Expand Up @@ -59,6 +59,34 @@ jobs:
name: build-output
path: preact.tgz

bench_todo:
name: Bench todo
runs-on: ubuntu-latest
needs: prepare
timeout-minutes: 10
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14.x'
- uses: actions/download-artifact@v2
with:
name: build-output
- name: install & build
run: |
cd benches
npm ci
- name: bench
run: |
export CHROMEDRIVER_FILEPATH=$(which chromedriver)
cd benches
npm run bench todo.html
- name: Upload results
uses: actions/upload-artifact@v2
with:
name: results
path: benches/results/todo.json

bench_text_update:
name: Bench text_update
runs-on: ubuntu-latest
Expand Down
19 changes: 19 additions & 0 deletions benches/proxy-packages/preact-hooks-proxy/index.js
@@ -0,0 +1,19 @@
import { render, hydrate } from 'preact';

export * from 'preact/hooks';
export * from 'preact';

/**
* @param {HTMLElement} rootDom
* @returns {{ render(vnode: JSX.Element): void; hydrate(vnode: JSX.Element): void; }}
*/
export function createRoot(rootDom) {
return {
render(vnode) {
render(vnode, rootDom);
},
hydrate(vnode) {
hydrate(vnode, rootDom);
}
};
}
10 changes: 10 additions & 0 deletions benches/proxy-packages/preact-hooks-proxy/package.json
@@ -0,0 +1,10 @@
{
"name": "preact-hooks-proxy",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "index.js",
"dependencies": {
"preact": "file:../../../"
}
}
2 changes: 1 addition & 1 deletion benches/scripts/bench.js
Expand Up @@ -21,7 +21,7 @@ export const defaultBenchOptions = {
// GitHub Action minutes
timeout: 1,
'window-size': '1024,768',
framework: IS_CI ? ['preact-master', 'preact-local'] : null,
framework: IS_CI ? ['preact-master', 'preact-local', 'preact-hooks'] : null,
trace: false
};

Expand Down
9 changes: 9 additions & 0 deletions benches/scripts/config.js
Expand Up @@ -69,6 +69,15 @@ export const frameworks = [
isValid() {
return validateFileDep(this.dependencies.framework);
}
},
{
label: 'preact-hooks',
dependencies: {
framework: 'file:' + repoRoot('benches/proxy-packages/preact-hooks-proxy')
},
isValid() {
return validateFileDep(this.dependencies.framework);
}
}
];

Expand Down
247 changes: 247 additions & 0 deletions benches/src/todo.html
@@ -0,0 +1,247 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ToDo List</title>
<style>
body {
padding: 20px;
font-family: system-ui;
}
a {
opacity: 0.5;
}
h1 {
margin-top: 0;
font-size: 150%;
font-weight: inherit;
}
ul {
list-style: none;
padding: 0;
}
li {
display: flex;
padding: 0 10px;
align-items: center;
}
li:nth-child(odd) {
background-color: #f0f6ff;
}
li > * {
display: inline-block;
flex: 0;
padding: 5px;
margin: 0;
}
li p {
flex: 1;
}
li[done] p {
opacity: 0.5;
text-decoration: line-through;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import {
mutateAndLayoutAsync,
sleep,
measureName,
measureMemory
} from './util.js';
import { createRoot, createElement as h, Component } from 'framework';

// Number of warmup runs of the benchmark to execute before the timed run
const WARMUP_COUNT = 5;

// Number of ToDo list items to render/toggle/delete
// NOTE: *must* be divisible by 4.
const NUM_ITEMS = 40;

const freshState = () => ({ counter: 0, text: '', todos: [] });
let state = freshState();

function mutation(fn) {
return e => {
rerender((state = Object.assign({}, state, fn(state, e))));
};
}

const add = mutation(({ counter, text, todos }, e) => {
e.preventDefault();
const id = ++counter;
return { counter, text: '', todos: todos.concat({ text, id }) };
});

const setText = mutation((state, e) => ({ text: e.target.value }));

const toggle = mutation(({ todos }, e) => {
const id = e.currentTarget.getAttribute('data-todo');
todos = todos.map(todo =>
todo.id == id ? { ...todo, done: !todo.done } : todo
);
return { todos };
});

const remove = mutation(({ todos }, e) => {
const id = e.currentTarget.getAttribute('data-todo');
todos = todos.filter(todo => todo.id != id);
return { todos };
});

function TodoItem({ todo }) {
return h(
'li',
{
done: todo.done,
'data-todo': todo.id,
onClick: toggle
},
h('input', {
type: 'checkbox',
checked: todo.done,
readonly: true
}),
h('p', null, todo.text),
h('a', { 'data-todo': todo.id, onClick: remove }, '✕')
);
}

function App({ text, todos }) {
return h(
'div',
null,
h(
'form',
{ onSubmit: add },
h('input', {
value: text,
onInput: setText,
placeholder: 'Enter a new to-do item...'
}),
h('button', { type: 'submit', disabled: !text }, 'Add')
),
h(
'ul',
null,
todos.map(todo => h(TodoItem, { key: todo.id, todo }))
)
);
}

const root = createRoot(document.getElementById('app'));
function rerender() {
root.render(h(App, state));
}
rerender();

const BUBBLING_EVENT = {};
function type(el, text) {
const OPTS = {
inputType: 'inserting',
data: '',
bubbles: true,
cancelable: true
};
let value = '';
for (let i = 0; i < text.length; i++) {
const ch = text[i];
value += ch;
OPTS.data = ch;
el.value = value;
el.dispatchEvent(new InputEvent('input', OPTS));
}
el.dispatchEvent(new InputEvent('change', OPTS));
}
const $ = sel => document.querySelector(sel);

function runPatch() {
state = freshState();
rerender();
const input = $('input');
const form = $('form');
const list = $('ul');
const button = $('button');
for (let i = 1; i <= NUM_ITEMS; i++) {
type(input, `Item ${i}`);
button.click();
const itemsInDom = list.children.length;
if (itemsInDom !== i) {
throw Error(`Expected ${i} ToDo list items, got ${itemsInDom}.`);
}
}
// this check also forces layout in order to include that time in test measured time:
if (list.offsetHeight < NUM_ITEMS * 5) {
throw Error(
`Expected list to have height > ${NUM_ITEMS * 5}, got ${
list.offsetHeight
}.`
);
}
const items = [].slice.call(list.children);
for (let i = 0; i < items.length; i++) {
items[i].click();
}
if (!items.every(item => item.hasAttribute('done'))) {
throw Error(`Expected all items to have [done] attribute.`);
}
for (let i = 0; i < items.length; i++) {
items[i].click();
}
if (items.some(item => item.hasAttribute('done'))) {
throw Error(
`Expected [done] attribute to be removed from all items.`
);
}
const count = NUM_ITEMS / 4;
for (let i = count; i < count * 3; i++) {
items[i].lastElementChild.click();
}
for (let i = 0; i < count; i++) {
items[i].lastElementChild.click();
}
for (let i = count * 4; i-- > count * 3; ) {
items[i].lastElementChild.click();
}
if (items.some(item => item.isConnected)) {
throw Error(`Expected all items to be removed from the DOM.`);
}
if (list.offsetHeight > 10) {
throw Error(
`Expected empty list to have a height of approximately 0px.`
);
}
root.render(null);
if ($('#app').children.length > 0) {
throw Error(`Expected entire application to be un-rendered.`);
}
}

async function warmup() {
for (let i = 0; i < WARMUP_COUNT; i++) {
await runPatch();
await new Promise(r => requestAnimationFrame(r));
}
}

warmup().then(async () => {
await sleep(200);

// This triggers a rAF, then runs a synchronous benchmark followed by one "tick",
// which should include the cost of layout in all current browsers.
await mutateAndLayoutAsync(() => {
performance.mark('start');
runPatch();
});
performance.mark('stop');
performance.measure(measureName, 'start', 'stop');

measureMemory();
});
</script>
</body>
</html>
32 changes: 32 additions & 0 deletions benches/src/util.js
Expand Up @@ -96,3 +96,35 @@ export function testElementTextContains(selector, expectedText) {
);
}
}

let count = 0;
const channel = new MessageChannel();
const callbacks = new Map();
channel.port1.onmessage = e => {
let id = e.data;
let fn = callbacks.get(id);
callbacks.delete(id);
fn();
};
let pm = function(callback) {
let id = ++count;
callbacks.set(id, callback);
this.postMessage(id);
}.bind(channel.port2);

export function nextTick() {
return new Promise(r => pm(r));
}

export function mutateAndLayoutAsync(mutation, times = 1) {
return new Promise(resolve => {
requestAnimationFrame(() => {
for (let i = 0; i < times; i++) {
mutation(i);
}
pm(resolve);
});
});
}

export const sleep = ms => new Promise(r => setTimeout(r, ms));
1 change: 1 addition & 0 deletions src/component.js
Expand Up @@ -211,4 +211,5 @@ function process() {
});
}
}

process._rerenderCount = 0;

0 comments on commit 1427d58

Please sign in to comment.