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
1 change: 1 addition & 0 deletions packages/haptic/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/haptic/src/haptic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
46 changes: 45 additions & 1 deletion packages/haptic/test/haptic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,51 @@ function auxiliary(container: HTMLElement) { return container.querySelector<HTML
beforeEach(() => {
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(<Haptic webHapticsOptions={webHapticsOptions}><button onClick={action}>Tap</button></Haptic>);
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(<Haptic webHapticsOptions={webHapticsOptions}><button onClick={action}>Tap</button></Haptic>);
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(<Haptic><button onClick={action}>Tap</button></Haptic>);
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', () => {
Expand Down