Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ playwright-report/

# Reviewed HIG candidate tarballs (see vendor/bloxwap-hig/README.md).
!vendor/bloxwap-hig/*.tgz

# Local benchmark measurements.
packages/haptic/bench/results/
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"docs:check": "npm --workspace @bloxwap/haptic-docs run check",
"docs:preview": "npm --workspace @bloxwap/haptic-docs run preview",
"docs:smoke": "npm --workspace @bloxwap/haptic-docs run smoke",
"lint": "node .github/scripts/hig-check.mjs && git diff --check"
"lint": "node .github/scripts/hig-check.mjs && git diff --check",
"bench": "npm --workspace @bloxwap/haptic run bench"
}
}
90 changes: 90 additions & 0 deletions packages/haptic/bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Haptic performance benchmark

Run `npm run bench` from the repository root with Node 24 and installed workspace
dependencies. It rebuilds dist, prints Markdown tables, and writes the environment,
configuration, summary measurements, per-cycle heap values, diagnostic counts, and
printed tables to `bench/results/latest.json` (ignored by git). Run twice on an idle
host and compare matching rows. No new dependencies or network access are needed.
The package's publication allowlist excludes this entire directory.

`run.mjs` coordinates bundles and isolated workers. `worker.mjs` runs SSR/client
scenarios; `lib/` holds sampling, subprocess capture, bundling, and resource probes.

- **Bundles:** esbuild bundles built dist for ES2020 with React external, minifies,
then measures bytes with Node's default gzip/Brotli compression. Full ESM/CJS
entries retain all exports. Single exports use `import { X }; console.log(X)`
to permit tree shaking. The two direct web-haptics imports isolate its presets
and its public engine class (including all class methods). Dependency bytes are
uncompressed `bytesInOutput` from esbuild, not attributable compressed bytes.
- **Cold imports:** nine fresh production Node processes after three discarded
runs per entry. The clock surrounds `await import()`, including dependencies
but excluding process startup. CJS uses Node's dynamic import interoperability.
React alone is the baseline; filesystem caches remain warm.
- **SSR:** production `renderToString` of preconstructed 1/100/1000-target trees.
`ms/render` is whole-tree latency; ops/sec is 1000 divided by median latency.
- **Client timings:** ordinary production React, no StrictMode, jsdom,
`createRoot` + `flushSync`. Production React 19 has no `act`; each update awaits
a timer macrotask, an immediate, and another timer, then flushes again. This settles passive
effects and the iOS routing update. These waits are included in the latency
and introduce an approximately two-millisecond floor. Mount includes root/
container creation and settling; unmount includes cleanup, settling, and host
removal. Both vibration and iOS mount/unmount use 100 and 1000 instances.
Rerenders create fresh elements/children, retaining stable option references
except for the named prop. Feedback alternates presets; option objects change
identity with unchanged fields. Each row has three warmups and nine samples.
- **Commits/renders:** an independent, untimed worker uses React's production
profiling renderer and a React `<Profiler>` around the tree. A temporary copy
of dist increments a counter at HapticTarget function entry to count actual
component invocations (Profiler alone counts tree commits, not invocations).
Counters are reset for each mount. Counts are reported as observed, not
asserted, so optimizations that remove renders do not break the benchmark;
only the iOS switch count is checked to confirm the iOS route ran.
The copied file is removed in `finally`; timed workers import untouched dist.
- **Warmed taps:** nine batches after three warmups, each dispatching 200 clicks
on one mounted instance; elapsed time including settling is divided by 200.
iOS clicks target its overlay label, activating the switch and host action.
Host actions and vibration calls are asserted. Imperative timing uses batches
of 1000 awaited `triggerHaptic` calls. These are dispatch costs, not physical
latency. The desktop warmed batch repeatedly restarts one engine's pattern;
it does not represent 200 completed animations.
- **Routing:** each mode runs in its own process. Navigator is stubbed before
importing dist because `WebHaptics.isSupported` is static at module evaluation.
Vibration returns true; iOS has no vibration and uses iPhone/AppleWebKit;
desktop has no vibration, Linux platform, and a UA without iPhone.
- **Resources:** a separate production 100-instance mount instruments listener
registration and MutationObserver creation. DOM counts include elements and
text nodes below body, excluding the root container. Body node deltas after
taps include engine nodes outside the React tree. Desktop's hidden engine
label contains text and a switch: three nodes per engine. First-tap deltas
refer to one instance; cumulative deltas follow one tap on each of 100 distinct
instances. Labels with `for="web-haptics-*"` independently count desktop engines.
Vibration engines create no labels; constructor counts therefore use the
separate instrumented dist worker (reported as `n/a` if the construction
site in dist changes). No allocation-per-tap estimate is attempted.
- **rAF and first-use costs:** only the resource pass substitutes a queued rAF
scheduler and drains each selection pattern with synthetic 60Hz timestamps.
Counts include every scheduled callback through completion (not just the
initial request). First-tap and remaining-99 average costs include dispatch,
instrumentation, and synchronous frame draining, excluding node census and
wall-clock animation waits. The remaining-99 average includes target lookup.
These single observations have no percentile and can be noisy. Actual frame
counts depend on pattern, refresh rate, throttling, and interruptions.
- **Cleanup:** unmount reports remaining observers/body nodes and listener
registrations. Adds/removes are API call counts, while retained counts track
unique target/type/callback/capture registrations. React retains delegated
listeners on detached root containers; these are collectible and their balance
is not evidence of a leak. Connected document/window listeners are separated.
Probes begin after framework setup and restore globals before heap sampling.
- **Memory:** vibration only, `--expose-gc`, three warmup mount/unmount cycles of
1000 untapped instances, then eight measured cycles with GC after each. The
displayed byte delta is final minus initial heap, not a median or percentile;
all cycle values are saved. This is a noisy retention signal, not leak proof.

Median and nearest-rank p95 use nine samples; p95 is therefore the maximum.
Compare runs on matching hardware, Node, and dependency versions. Two runs can
show repeatability but cannot establish statistical variance or causality.
jsdom has no layout/paint or real hardware: synthetic clicks cannot establish
trusted iOS activation, tactile delivery, browser frame budgets, or accessibility
behavior. Profiling and resource observations explain work performed, but are
not additive timing attribution. Source optimizations require separate A/B
measurements and browser/hydration/accessibility regression checks.
56 changes: 56 additions & 0 deletions packages/haptic/bench/lib/bundles.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { build } from 'esbuild';
import { gzipSync, brotliCompressSync } from 'node:zlib';

function dependencyBytes(metafile) {
let bytes = 0;
for (const output of Object.values(metafile.outputs)) {
for (const [path, input] of Object.entries(output.inputs)) {
if (path.includes('web-haptics/')) bytes += input.bytesInOutput;
}
}
return bytes;
}

export async function measureBundles(cwd) {
const entries = [
['Full ESM', 'index.js', null, 'esm'],
['Full CJS', 'index.cjs', null, 'cjs'],
...['triggerHaptic', 'canTriggerHaptic', 'isIOSWebKitWithoutVibration', 'useIOSNativeHaptics', 'HapticTarget']
.map(name => [name, 'index.js', name, 'esm']),
['createHapticAdapter', 'feedback.js', 'createHapticAdapter', 'esm'],
['web-haptics defaultPatterns', null, 'defaultPatterns', 'esm'],
['web-haptics WebHaptics', null, 'WebHaptics', 'esm'],
];
const bundles = [];
for (const [name, file, exportName, format] of entries) {
const specifier = file ? `./dist/${file}` : 'web-haptics';
const entry = exportName ? {
stdin: {
contents: `import { ${exportName} } from '${specifier}'; console.log(${exportName});`,
resolveDir: cwd,
sourcefile: 'bench-entry.js',
},
} : { entryPoints: [`${cwd}/dist/${file}`] };
const result = await build({
...entry,
bundle: true,
minify: true,
write: false,
metafile: true,
format,
platform: 'browser',
target: 'es2020',
external: ['react', 'react/*'],
logLevel: 'silent',
});
const bytes = result.outputFiles[0].contents;
bundles.push({
name,
minified: bytes.length,
gzip: gzipSync(bytes).length,
brotli: brotliCompressSync(bytes).length,
webHapticsBytes: dependencyBytes(result.metafile),
});
}
return bundles;
}
21 changes: 21 additions & 0 deletions packages/haptic/bench/lib/process.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { spawn } from 'node:child_process';
import { mkdtemp, open, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export async function run(file, args, options) {
// File-backed output also works in restricted hosts with unavailable pipe capture.
const directory = await mkdtemp(join(tmpdir(), 'haptic-bench-'));
const output = join(directory, 'stdout');
const handle = await open(output, 'w');
try {
await new Promise((resolve, reject) => {
const child = spawn(file, args, { ...options, stdio: ['ignore', handle.fd, 'inherit'] });
child.on('error', reject);
child.on('close', (code, signal) => code === 0 ? resolve() : reject(Error(`${file} exited ${code ?? signal}`)));
});
return await readFile(output, 'utf8');
} finally {
await handle.close();
await rm(directory, { recursive: true, force: true });
}
}
150 changes: 150 additions & 0 deletions packages/haptic/bench/lib/resources.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { performance } from 'node:perf_hooks';

function bodyNodeCount() {
const elements = [...document.body.querySelectorAll('*')];
const textNodes = elements.reduce((total, element) => {
return total + [...element.childNodes].filter(node => node.nodeType === 3).length;
}, 0);
return elements.length + textNodes;
}

export async function measureResources({ mount, unmount, ios, mode }) {
// Instrument after React/jsdom initialization; keep tracking references out of the memory test.
const proto = EventTarget.prototype;
const add = proto.addEventListener;
const remove = proto.removeEventListener;
let adds = 0;
let removes = 0;
let created = 0;
let disconnected = 0;
const live = new Map();
const capture = o => typeof o === 'boolean' ? o : !!o?.capture;
proto.addEventListener = function (type, fn, options) {
adds++;
let records = live.get(this);
if (!records) {
live.set(this, records = []);
}
if (!records.some(r => r.type === type && r.fn === fn && r.capture === capture(options))) {
records.push({ type, fn, capture: capture(options) });
}
return add.call(this, type, fn, options);
};
proto.removeEventListener = function (type, fn, options) {
removes++;
const records = live.get(this);
if (records) {
const i = records.findIndex(r => r.type === type && r.fn === fn && r.capture === capture(options));
if (i >= 0) {
records.splice(i, 1);
}
}
return remove.call(this, type, fn, options);
};
const Original = globalThis.MutationObserver;
const observers = new Set();
globalThis.MutationObserver = class extends Original {
constructor(cb) {
super(cb);
created++;
observers.add(this);
}
disconnect() {
disconnected++;
observers.delete(this);
return super.disconnect();
}
};
const before = bodyNodeCount();
const resourceItem = await mount(100);
const mountNodes = bodyNodeCount() - before - 1;
const mounted = { adds, removes, created };
const preTap = bodyNodeCount();
const tap = resourceItem.host.querySelector(ios ? 'label' : 'button');
let scheduledFrames = 0;
let nextFrame = 0;
const frames = new Map();
const originalRAF = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = callback => {
scheduledFrames++;
frames.set(++nextFrame, callback);
return nextFrame;
};
globalThis.cancelAnimationFrame = id => frames.delete(id);
function tapAndDrain(target) {
target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
// Simulate 60Hz frames until the selection pattern completes.
let timestamp = 100;
while (frames.size) {
const pending = [...frames.values()];
frames.clear();
for (const callback of pending) {
callback(timestamp);
}
timestamp += 1000 / 60;
if (timestamp > 10000) {
throw Error('Pattern did not finish');
}
}
}
const started = performance.now();
tapAndDrain(tap);
const firstTapMilliseconds = performance.now() - started;
const firstTap = {
nodes: bodyNodeCount() - preTap,
listeners: adds - mounted.adds,
observers: created - mounted.created,
labels: document.body.querySelectorAll('label[for^="web-haptics-"]').length,
raf: scheduledFrames,
milliseconds: firstTapMilliseconds,
};
const remainingStarted = performance.now();
for (const target of [...resourceItem.host.querySelectorAll(ios ? 'label' : 'button')].slice(1)) {
tapAndDrain(target);
}
const remainingMilliseconds = performance.now() - remainingStarted;
const allTaps = {
nodes: bodyNodeCount() - preTap,
labels: document.body.querySelectorAll('label[for^="web-haptics-"]').length,
rafPerTap: scheduledFrames / 100,
millisecondsPerTap: remainingMilliseconds / 99,
};
await unmount(resourceItem);
let retainedOnDocument = 0;
let retainedOnDetached = 0;
for (const [target, records] of live) {
if (target === window || target === document || target.isConnected) {
retainedOnDocument += records.length;
} else {
retainedOnDetached += records.length;
}
}
const resources = {
path: mode,
instances: 100,
nodesPerInstance: mountNodes / 100,
listenersAddedPerInstance: mounted.adds / 100,
observersPerInstance: mounted.created / 100,
firstTap,
allTaps,
afterUnmount: {
adds,
removes,
balance: adds - removes,
activeObservers: observers.size,
observerDisconnects: disconnected,
leftoverBodyNodes: bodyNodeCount() - before,
retainedOnDocument,
retainedOnDetached,
},
};
globalThis.requestAnimationFrame = originalRAF;
globalThis.cancelAnimationFrame = originalCancel;
proto.addEventListener = add;
proto.removeEventListener = remove;
globalThis.MutationObserver = Original;
live.clear();
observers.clear();
return resources;
}
29 changes: 29 additions & 0 deletions packages/haptic/bench/lib/stats.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { performance } from 'node:perf_hooks';

export const rows = [];

export function summarize(name, values, unit = 'ms') {
values.sort((a, b) => a - b);
const median = values[Math.floor(values.length / 2)];
rows.push({
name,
median,
p95: values[Math.ceil(values.length * 0.95) - 1],
unit,
samples: values.length,
...(unit === 'ms/render' ? { opsPerSecond: 1000 / median } : {}),
});
}

export async function sample(name, action, unit, repetitions = 9, divisor = 1) {
for (let i = 0; i < 3; i++) {
await action();
}
const values = [];
for (let i = 0; i < repetitions; i++) {
const start = performance.now();
await action();
values.push((performance.now() - start) / divisor);
}
summarize(name, values, unit);
}
Loading