diff --git a/.gitignore b/.gitignore index 3b42d07..77776eb 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/package.json b/package.json index 1be6f50..279bb10 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/haptic/bench/README.md b/packages/haptic/bench/README.md new file mode 100644 index 0000000..90d2594 --- /dev/null +++ b/packages/haptic/bench/README.md @@ -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 `` 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. diff --git a/packages/haptic/bench/lib/bundles.mjs b/packages/haptic/bench/lib/bundles.mjs new file mode 100644 index 0000000..36ce450 --- /dev/null +++ b/packages/haptic/bench/lib/bundles.mjs @@ -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; +} diff --git a/packages/haptic/bench/lib/process.mjs b/packages/haptic/bench/lib/process.mjs new file mode 100644 index 0000000..39e1d83 --- /dev/null +++ b/packages/haptic/bench/lib/process.mjs @@ -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 }); + } +} diff --git a/packages/haptic/bench/lib/resources.mjs b/packages/haptic/bench/lib/resources.mjs new file mode 100644 index 0000000..416ffd5 --- /dev/null +++ b/packages/haptic/bench/lib/resources.mjs @@ -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; +} diff --git a/packages/haptic/bench/lib/stats.mjs b/packages/haptic/bench/lib/stats.mjs new file mode 100644 index 0000000..cada629 --- /dev/null +++ b/packages/haptic/bench/lib/stats.mjs @@ -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); +} diff --git a/packages/haptic/bench/run.mjs b/packages/haptic/bench/run.mjs new file mode 100644 index 0000000..ccd7485 --- /dev/null +++ b/packages/haptic/bench/run.mjs @@ -0,0 +1,137 @@ +import { measureBundles } from './lib/bundles.mjs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { run } from './lib/process.mjs'; +import { cpus, totalmem, platform, release } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const cwd = fileURLToPath(new URL('..', import.meta.url)); +const environment = { + node: process.version, + os: `${platform()} ${release()}`, + cpu: cpus()[0]?.model, + cores: cpus().length, + totalMemory: totalmem(), + commit: (await run('git', ['rev-parse', 'HEAD'], { cwd })).trim(), + date: new Date().toISOString(), +}; +const bundles = await measureBundles(cwd); +async function measureColdImports() { + const rows = []; + const entries = [['react', 'react'], ['ESM', './dist/index.js'], ['CJS', './dist/index.cjs']]; + for (const [name, target] of entries) { + const times = []; + const script = ` + const start = performance.now(); + await import(${JSON.stringify(target)}); + console.log(performance.now() - start); + `; + for (let iteration = 0; iteration < 12; iteration++) { + const output = await run(process.execPath, ['--input-type=module', '-e', script], { + cwd, + env: { ...process.env, NODE_ENV: 'production' }, + }); + if (iteration >= 3) times.push(Number(output)); + } + times.sort((a, b) => a - b); + rows.push({ name: `Cold import ${name}`, median: times[4], p95: times[8], unit: 'ms', samples: 9 }); + } + return rows; +} + +const rows = await measureColdImports(); + +const resources = []; +for (const mode of ['ssr', 'vibration', 'ios', 'desktop']) { + const output = await run(process.execPath, ['--expose-gc', 'bench/worker.mjs', mode], { + cwd, + timeout: 120000, + env: { ...process.env, NODE_ENV: 'production' }, + }); + if (!output.trim()) { + throw Error(`Empty worker output: ${mode}`); + } + const result = JSON.parse(output); + rows.push(...result.rows); + if (result.resources) { + resources.push(result.resources); + } +} + +// Instrument only a separate diagnostic build; timing workers import untouched dist. +const source = await readFile(`${cwd}/dist/index.js`, 'utf8'); +const instrumented = source + .replace(' if (!isValidElement(children))', ' globalThis.__hapticBench.renders++;\n if (!isValidElement(children))') + .replace('new WebHaptics(options.webHapticsOptions)', '(globalThis.__hapticBench.engines++, new WebHaptics(options.webHapticsOptions))'); +if (!instrumented.includes('__hapticBench.renders++')) { + throw Error('Render instrumentation failed: HapticTarget entry point not found in dist/index.js'); +} +// Engine counting depends on the current construction site; report "n/a" if it moves. +const enginesInstrumented = instrumented.includes('__hapticBench.engines++'); +await mkdir(`${cwd}/bench/results`, { recursive: true }); +await writeFile(`${cwd}/bench/results/instrumented.mjs`, instrumented); +const diagnostics = []; +try { + for (const mode of ['vibration', 'ios', 'desktop']) { + diagnostics.push(JSON.parse(await run(process.execPath, ['bench/worker.mjs', mode, 'diagnostic'], { + cwd, + env: { ...process.env, NODE_ENV: 'production' }, + }))); + } +} finally { + await rm(`${cwd}/bench/results/instrumented.mjs`, { force: true }); +} +const formatNumber = value => value === null ? '—' : value.toFixed(6); +const lines = [ + `Environment: ${environment.node}; ${environment.os}; ${environment.cpu}; ${environment.cores} cores; ${(environment.totalMemory / 2 ** 30).toFixed(1)} GiB RAM; ${environment.commit}; ${environment.date}`, + 'Build: production React for timings; production profiling renderer + instrumented dist for untimed counts.', + '', + '| Bundle | Min bytes | Gzip bytes | Brotli bytes | web-haptics bytes¹ |', + '| --- | ---: | ---: | ---: | ---: |', + ...bundles.map(bundle => `| ${bundle.name} | ${bundle.minified} | ${bundle.gzip} | ${bundle.brotli} | ${bundle.webHapticsBytes} |`), + '', + '| Operation | Median | p95 | Unit | Ops/sec² |', + '| --- | ---: | ---: | --- | ---: |', + ...rows.map(row => `| ${row.name} | ${formatNumber(row.median)} | ${formatNumber(row.p95)} | ${row.unit} | ${row.opsPerSecond?.toFixed(1) ?? '—'} |`), + '', + '| Path | Instances | Commits per mount | HapticTarget renders per instance |', + '| --- | ---: | ---: | ---: |', + ...diagnostics.flatMap(result => result.diagnostics.map(count => `| ${result.path} | ${count.instances} | ${count.commits} | ${count.rendersPerInstance} |`)), + '', + '| Resources (100 instances) | Vibration | iOS | Desktop without vibration |', + '| --- | ---: | ---: | ---: |', +]; +for (const [label, read] of [ + ['DOM nodes/instance', resource => resource.nodesPerInstance], + ['Listener adds/instance³', resource => resource.listenersAddedPerInstance], + ['Observers/instance', resource => resource.observersPerInstance], + ['First tap: body nodes / engine labels / rAF scheduled', resource => `${resource.firstTap.nodes} / ${resource.firstTap.labels} / ${resource.firstTap.raf}`], + ['100 distinct taps: body nodes / engine labels / rAF per tap', resource => `${resource.allTaps.nodes} / ${resource.allTaps.labels} / ${resource.allTaps.rafPerTap}`], + ['Engines constructed after 100 distinct taps⁴', resource => enginesInstrumented ? diagnostics.find(result => result.path === resource.path).engines : 'n/a'], + ['First tap cost (instrumented ms)', resource => resource.firstTap.milliseconds.toFixed(6)], + ['Remaining 99 distinct taps (instrumented ms/tap)', resource => resource.allTaps.millisecondsPerTap.toFixed(6)], + ['Unmount: listener adds / removes / balance³', resource => `${resource.afterUnmount.adds} / ${resource.afterUnmount.removes} / ${resource.afterUnmount.balance}`], + ['Unmount: active observers / body nodes', resource => `${resource.afterUnmount.activeObservers} / ${resource.afterUnmount.leftoverBodyNodes}`], + ['Unmount: listeners on document / detached nodes³', resource => `${resource.afterUnmount.retainedOnDocument} / ${resource.afterUnmount.retainedOnDetached}`], +]) { + lines.push(`| ${label} | ${resources.map(read).join(' | ')} |`); +} +lines.push( + '', + '¹ Uncompressed esbuild contribution; shared compression cannot be attributed per dependency.', + '² SSR only. All timings use production React, createRoot + flushSync; passive effects settle across macrotasks.', + '³ Includes delegated root listeners; detached listeners are collectible, not proof of a leak.', + '⁴ Constructor counts use a separate instrumented build; desktop label counts independently corroborate engines.', + 'Tap timing rows are warmed batch-normalized dispatch costs. Resource tap costs include instrumentation and a simulated 60Hz rAF drain.', +); +const markdown = lines.join('\n'); +await mkdir(`${cwd}/bench/results`, { recursive: true }); +await writeFile(`${cwd}/bench/results/latest.json`, JSON.stringify({ + environment, + configuration: { warmups: 3, samples: 9, importSamples: 9, memoryCycles: 8, memoryInstances: 1000 }, + bundles, + rows, + resources, + diagnostics, + markdown, +}, null, 2) + '\n'); +console.log(markdown); diff --git a/packages/haptic/bench/worker.mjs b/packages/haptic/bench/worker.mjs new file mode 100644 index 0000000..6a672d6 --- /dev/null +++ b/packages/haptic/bench/worker.mjs @@ -0,0 +1,219 @@ +import { performance } from 'node:perf_hooks'; +import { rows, sample, summarize } from './lib/stats.mjs'; +import { measureResources } from './lib/resources.mjs'; + +const mode = process.argv[2]; +const diagnostic = process.argv[3] === 'diagnostic'; + +if (mode === 'ssr') { + const React = await import('react'); + const { renderToString } = await import('react-dom/server'); + const { HapticTarget } = await import('../dist/index.js'); + for (const count of [1, 100, 1000]) { + const targets = Array.from({ length: count }, (_, key) => React.createElement( + HapticTarget, { key }, React.createElement('button', null, 'Tap'), + )); + const tree = React.createElement(React.Fragment, null, ...targets); + await sample(`SSR ${count}`, () => renderToString(tree), 'ms/render'); + } +} else { + const { JSDOM } = await import('jsdom'); + const dom = new JSDOM('', { + pretendToBeVisual: true, + url: 'https://bench.invalid', + }); + for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Node', 'Event', 'MouseEvent', 'EventTarget', 'MutationObserver']) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value: dom.window[key], + }); + } + globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); + globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); + const ios = mode === 'ios'; + let vibrations = 0; + let activations = 0; + // Stub before importing dist: WebHaptics.isSupported is evaluated once. + Object.defineProperties(navigator, { + vibrate: { + configurable: true, + value: mode === 'vibration' ? () => { + vibrations++; + return true; + } : undefined, + }, + platform: { configurable: true, value: ios ? 'iPhone' : 'Linux' }, + userAgent: { configurable: true, value: ios ? 'iPhone AppleWebKit/605.1.15' : 'bench' }, + }); + const React = await import('react'); + const { createRoot, flushSync: profilingFlushSync } = await import(diagnostic ? 'react-dom/profiling' : 'react-dom/client'); + const { flushSync: normalFlushSync } = await import('react-dom'); + const flushSync = diagnostic ? profilingFlushSync : normalFlushSync; + // Production React has no act. Allow passive effects and their scheduled + // state updates to settle across macrotasks after each synchronous commit. + async function settleUpdates(action) { + flushSync(action); + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setImmediate(resolve)); + // Overlay mutations can enqueue another update through MutationObserver. + await new Promise(resolve => setTimeout(resolve, 0)); + flushSync(() => {}); + } + globalThis.__hapticBench = { renders: 0, engines: 0 }; + const { HapticTarget, triggerHaptic, isIOSWebKitWithoutVibration } = await import( + diagnostic ? './results/instrumented.mjs' : '../dist/index.js' + ); + if (isIOSWebKitWithoutVibration() !== ios) throw Error('Routing stub failed'); + const stable = { + feedback: 'selection', + triggerOptions: { intensity: 0.5 }, + webHapticsOptions: { debug: false }, + }; + function tree(count, props = stable) { + const targets = Array.from({ length: count }, (_, key) => React.createElement( + HapticTarget, + { key, ...props }, + React.createElement('button', { onClick: () => activations++ }, 'Tap'), + )); + return React.createElement(React.Fragment, null, ...targets); + } + async function mount(n, props) { + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + await settleUpdates(() => root.render(tree(n, props))); + return { root, host }; + } + async function unmount({ root, host }) { + await settleUpdates(() => root.unmount()); + host.remove(); + } + if (diagnostic) { + const diagnostics = []; + for (const n of [100, 1000]) { + let commits = 0; + globalThis.__hapticBench.renders = 0; + const host = document.createElement('div'); + document.body.append(host); + const root = createRoot(host); + await settleUpdates(() => root.render(React.createElement(React.Profiler, { + id: mode, + onRender: () => commits++, + }, tree(n)))); + const rendersPerInstance = globalThis.__hapticBench.renders / n; + // Report counts as measured; only check that the iOS route really rendered its switches. + if (ios && host.querySelectorAll('input[switch]').length !== n) { + throw Error(`iOS route did not render ${n} switches`); + } + diagnostics.push({ instances: n, commits, rendersPerInstance }); + await unmount({ root, host }); + } + const item = await mount(100); + const beforeEngines = globalThis.__hapticBench.engines; + for (const target of item.host.querySelectorAll('button')) { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + const engines = globalThis.__hapticBench.engines - beforeEngines; + await unmount(item); + dom.window.close(); + console.log(JSON.stringify({ path: mode, diagnostics, engines })); + process.exit(0); + } + if (mode === 'vibration' || ios) { + for (const n of [100, 1000]) { + const mounts = []; + const unmounts = []; + for (let i = 0; i < 12; i++) { + const mountStarted = performance.now(); + const item = await mount(n); + const mountMilliseconds = performance.now() - mountStarted; + const unmountStarted = performance.now(); + await unmount(item); + if (i >= 3) { + mounts.push(mountMilliseconds); + unmounts.push(performance.now() - unmountStarted); + } + } + summarize(`${mode} client mount ${n}`, mounts); + summarize(`${mode} client unmount ${n}`, unmounts); + } + const item = await mount(100); + for (const kind of ['stable', 'feedback', 'triggerOptions', 'webHapticsOptions']) { + let iteration = 0; + await sample(`${mode} re-render 100 ${kind}`, () => { + iteration++; + const props = kind === 'stable' ? stable : { + ...stable, [kind]: kind === 'feedback' ? (iteration % 2 ? 'light' : 'selection') : { ...stable[kind] } + }; + return settleUpdates(() => item.root.render(tree(100, props))); + }, 'ms'); + } + await unmount(item); + } + const tapItem = await mount(1); + const target = tapItem.host.querySelector(ios ? 'label' : 'button'); + if (!target) { + throw Error('Missing tap target'); + } + await sample(`${mode} ${ios ? 'label/switch' : 'button'} click`, async () => { + await settleUpdates(() => { + for (let i = 0; i < 200; i++) { + target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + }); + }, 'ms/tap', 9, 200); + if (activations < 2400 || (mode === 'vibration' && vibrations < 2400)) { + throw Error('Click batch did not exercise the expected path'); + } + await unmount(tapItem); + if (mode === 'vibration') { + await sample('Imperative triggerHaptic', async () => { + for (let i = 0; i < 1000; i++) { + const result = await triggerHaptic(); + if (!result.delivered) { + throw Error('Vibration rejected'); + } + } + }, 'ms/tap', 9, 1000); + } + if (!activations || (mode === 'vibration' && !vibrations)) { + throw Error('Tap path did not execute'); + } + const resources = await measureResources({ mount, unmount, ios, mode }); + if (mode === 'vibration') { + if (!global.gc) { + throw Error('Run with --expose-gc'); + } + async function cycle() { + const item = await mount(1000); + await unmount(item); + await new Promise(resolve => setImmediate(resolve)); + } + for (let i = 0; i < 3; i++) { + await cycle(); + } + global.gc(); + const beforeHeap = process.memoryUsage().heapUsed; + const heaps = []; + for (let i = 0; i < 8; i++) { + await cycle(); + global.gc(); + heaps.push(process.memoryUsage().heapUsed); + } + rows.push({ + name: 'Heap delta: 8 cycles × 1000', + median: heaps.at(-1) - beforeHeap, + p95: null, + unit: 'bytes', + samples: 8, + heapBefore: beforeHeap, + heapAfterEachCycle: heaps, + }); + } + dom.window.close(); + console.log(JSON.stringify({ rows, resources })); +} +if (mode === 'ssr') { + console.log(JSON.stringify({ rows })); +} diff --git a/packages/haptic/package.json b/packages/haptic/package.json index edd0bd6..6baae54 100644 --- a/packages/haptic/package.json +++ b/packages/haptic/package.json @@ -78,7 +78,8 @@ "test:browser": "playwright test", "pack:check": "npm run build && node scripts/pack-check.mjs", "prepack": "npm run build", - "prepublishOnly": "npm run typecheck && npm run coverage && npm run pack:check" + "prepublishOnly": "npm run typecheck && npm run coverage && npm run pack:check", + "bench": "npm run build && node bench/run.mjs" }, "devDependencies": { "@axe-core/playwright": "4.11.0",