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