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', () => {