Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add todo benchmark and add a proxy package that uses preact/hooks #3708

Merged
merged 6 commits into from Sep 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;