From 2856cb1ee97d1869f83dca1cb78f45b8ebc379d2 Mon Sep 17 00:00:00 2001
From: Joe Blau <1218847+joeblau@users.noreply.github.com>
Date: Tue, 20 Oct 2026 17:47:18 -0700
Subject: [PATCH] perf: skip the web-haptics engine on desktop browsers without
vibration
---
packages/haptic/CHANGELOG.md | 1 +
packages/haptic/src/haptic.tsx | 1 +
packages/haptic/test/haptic.test.tsx | 46 +++++++++++++++++++++++++++-
3 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/packages/haptic/CHANGELOG.md b/packages/haptic/CHANGELOG.md
index bcdb413..52454ee 100644
--- a/packages/haptic/CHANGELOG.md
+++ b/packages/haptic/CHANGELOG.md
@@ -5,6 +5,7 @@
- Use a local preset table so importing `triggerHaptic` does not bundle the web-haptics engine.
- Create host observers only for native iOS targets and block native overlays until the committed host is enabled.
- **Behavior change:** `useIOSNativeHaptics()` now resolves on the first client-only render (for example `createRoot` mounts and client navigation), so on iOS it returns `true` on the first render instead of after an effect. Server rendering and the first hydration render still return `false`, with iOS routing resolved after hydration.
+- Skip the `web-haptics` engine, its hidden DOM, and its animation-frame loop on desktop browsers without vibration unless debug audio or the visible switch is enabled.
## 0.2.0 — 2026-10-19
diff --git a/packages/haptic/src/haptic.tsx b/packages/haptic/src/haptic.tsx
index af1f069..1dda4f2 100644
--- a/packages/haptic/src/haptic.tsx
+++ b/packages/haptic/src/haptic.tsx
@@ -106,6 +106,7 @@ export function HapticTarget({
const options = current.current;
const host = readHost();
if (!host || !host.contains(event.target as Node) || options.native || !options.enabled || options.disabled || hostDisabled(host)) return;
+ if (typeof navigator.vibrate !== 'function' && !options.webHapticsOptions?.debug && !options.webHapticsOptions?.showSwitch) return;
try {
engine.current ??= new WebHaptics(options.webHapticsOptions);
void engine.current.trigger(options.feedback, options.triggerOptions).catch(() => {});
diff --git a/packages/haptic/test/haptic.test.tsx b/packages/haptic/test/haptic.test.tsx
index 0c241ab..4b3988d 100644
--- a/packages/haptic/test/haptic.test.tsx
+++ b/packages/haptic/test/haptic.test.tsx
@@ -24,7 +24,51 @@ function auxiliary(container: HTMLElement) { return container.querySelector {
engine.construct.mockReset(); engine.trigger.mockReset(); engine.cancel.mockReset(); engine.destroy.mockReset();
engine.trigger.mockResolvedValue(undefined);
- Reflect.deleteProperty(navigator, 'vibrate');
+ Object.defineProperty(navigator, 'vibrate', { configurable: true, value: vi.fn(() => true) });
+});
+
+describe('unsupported desktop', () => {
+ beforeEach(() => {
+ vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux');
+ vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 desktop');
+ Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined });
+ });
+
+ it.each([undefined, { debug: false, showSwitch: false }])('skips the engine and body nodes with options %j', webHapticsOptions => {
+ const action = vi.fn();
+ const view = render();
+ const append = vi.spyOn(document.body, 'appendChild');
+ const nodes = document.body.innerHTML;
+ fireEvent.click(view.getByRole('button'));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(engine.construct).not.toHaveBeenCalled();
+ expect(engine.trigger).not.toHaveBeenCalled();
+ expect(append).not.toHaveBeenCalled();
+ expect(document.body.innerHTML).toBe(nodes);
+ });
+
+ it.each([{ debug: true }, { showSwitch: true }])('preserves desktop feedback with %j', webHapticsOptions => {
+ const action = vi.fn();
+ const view = render();
+ fireEvent.click(view.getByRole('button'));
+ expect(action).toHaveBeenCalledTimes(1);
+ expect(engine.construct).toHaveBeenCalledExactlyOnceWith(webHapticsOptions);
+ expect(engine.trigger).toHaveBeenCalledExactlyOnceWith('selection', undefined);
+ });
+
+ it('checks vibration support on each tap', () => {
+ const action = vi.fn();
+ const view = render();
+ const host = view.getByRole('button');
+ fireEvent.click(host);
+ Object.defineProperty(navigator, 'vibrate', { configurable: true, value: () => true });
+ fireEvent.click(host);
+ Object.defineProperty(navigator, 'vibrate', { configurable: true, value: undefined });
+ fireEvent.click(host);
+ expect(action).toHaveBeenCalledTimes(3);
+ expect(engine.construct).toHaveBeenCalledTimes(1);
+ expect(engine.trigger).toHaveBeenCalledTimes(1);
+ });
});
describe('vibration target', () => {