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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ It recognizes iPadOS desktop-mode identities, but routing recognition does not e

## Hook

`useIOSNativeHaptics(): boolean` returns false on the server and initial render, then resolves platform routing after mount. Use it only inside a React component or hook. It does not detect whether the user can physically feel the feedback.
`useIOSNativeHaptics(): boolean` returns the detected routing value on the first client-only render. It returns `false` on the server and during the first hydration render, then resolves client routing after hydration. Use it only inside a React component or hook. It does not detect whether the user can physically feel the feedback.

## Public types

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ Disabling feedback leaves the original action available. A target with `disabled

## Next.js

Put interactive examples in a client component with `'use client'`. The published build preserves its client boundary and still renders safely through React server rendering. Platform detection resolves after mounting to keep initial markup compatible with hydration.
Put interactive examples in a client component with `'use client'`. The published build preserves its client boundary and still renders safely through React server rendering. Platform detection resolves on the first client-only render. Hydrated renders keep the server routing value until hydration completes so initial markup agrees.
1 change: 1 addition & 0 deletions packages/haptic/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,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.

## 0.2.0 — 2026-10-19

Expand Down
2 changes: 2 additions & 0 deletions packages/haptic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Physical-device and VoiceOver certification remains pending in [issue #9](https:

Exports also include `isIOSWebKitWithoutVibration`, `useIOSNativeHaptics`, `canTriggerHaptic`, `triggerHaptic`, `HapticTargetProps`, `HapticCapability`, `HapticTriggerResult`, `HapticInput`, `HapticPattern`, `HapticPreset`, `TriggerOptions`, `Vibration`, and `WebHapticsOptions`. The wrapper/platform code is independent; feedback types and the vibration engine use the declared `web-haptics` dependency.

`useIOSNativeHaptics()` returns the detected routing value on the first client-only render. It returns `false` on the server and during the first hydration render, then resolves client routing after hydration.

`canTriggerHaptic()` probes current imperative availability without creating DOM. `triggerHaptic(cue?, options?)` requests browser vibration with the same patterns and resolves `{ delivered, mechanism, reason? }` without rejecting or creating auxiliary DOM. `delivered` means browser acceptance, not observed tactile output. iOS imperative feedback reports unavailable even during transient activation: programmatic switch clicks do not establish a trusted physical tap. Use `HapticTarget` for the eligible iOS physical path.

## Documentation
Expand Down
14 changes: 9 additions & 5 deletions packages/haptic/src/platform.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';

/** Select the native-switch path; this is routing, not a hardware capability test. */
export function isIOSWebKitWithoutVibration(
Expand All @@ -12,9 +12,13 @@ export function isIOSWebKitWithoutVibration(
return appleMobile && /AppleWebKit/.test(navigatorLike.userAgent);
}

/** Resolve routing after mount so server and initial client markup agree. */
const noopSubscribe = () => () => {};
const serverSnapshot = () => false;

/**
* Resolve routing on the first client-only render. Server rendering and the first
* hydration render return false, then resolve client routing after hydration.
*/
export function useIOSNativeHaptics(): boolean {
const [native, setNative] = useState(false);
useEffect(() => { setNative(isIOSWebKitWithoutVibration()); }, []);
return native;
return useSyncExternalStore(noopSubscribe, isIOSWebKitWithoutVibration, serverSnapshot);
}
50 changes: 44 additions & 6 deletions packages/haptic/test/platform.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, render, renderHook } from '@testing-library/react';
import { StrictMode } from 'react';
import { renderToString } from 'react-dom/server';
import { hydrateRoot } from 'react-dom/client';
import { createRoot, hydrateRoot } from 'react-dom/client';
import { describe, expect, it, vi } from 'vitest';
import { Haptic, isIOSWebKitWithoutVibration, useIOSNativeHaptics } from '../src';

Expand Down Expand Up @@ -40,20 +40,58 @@ describe('platform contract', () => {
expect(result.current).toBe(true);
});

it('returns false during server rendering and hydrates before adding controls', async () => {
it('returns native routing on the first client-only createRoot render', async () => {
vi.stubGlobal('navigator', identity('iPhone', 'AppleWebKit', 5));
function HookOutput() { return <output>{String(useIOSNativeHaptics())}</output>; }
expect(renderToString(<HookOutput />)).toContain('false');
const tree = <Haptic><button>Hydrate</button></Haptic>;
const renders: boolean[] = [];
function HookOutput() {
const native = useIOSNativeHaptics();
renders.push(native);
return <output>{String(native)}</output>;
}
const container = document.createElement('div');
container.innerHTML = renderToString(tree);
const root = createRoot(container);
await act(async () => root.render(<HookOutput />));
expect(renders).toEqual([true]);
expect(container.textContent).toBe('true');
await act(async () => root.unmount());
});

it('preserves server markup and hydrates before adding native controls', async () => {
const renders: boolean[] = [];
function Example() {
renders.push(useIOSNativeHaptics());
return <Haptic><button>Hydrate</button></Haptic>;
}
const tree = <Example />;
// Server rendering must not read browser globals, even when they exist.
const navigatorRead = vi.fn(() => { throw new Error('navigator read during SSR'); });
vi.stubGlobal('navigator', undefined);
const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator')!;
Object.defineProperty(globalThis, 'navigator', { configurable: true, get: navigatorRead });
let markup: string;
try {
markup = renderToString(tree);
} finally {
Object.defineProperty(globalThis, 'navigator', navigatorDescriptor);
}
expect(navigatorRead).not.toHaveBeenCalled();
expect(markup).toBe('<span style="position:relative;display:inline-grid"><button>Hydrate</button></span>');
expect(renders).toEqual([false]);
vi.stubGlobal('navigator', identity('iPhone', 'AppleWebKit', 5));
expect(renderToString(tree)).toBe(markup);
renders.length = 0;
const container = document.createElement('div');
container.innerHTML = markup;
document.body.append(container);
expect(container.querySelector('input')).toBeNull();
const errors = vi.fn();
const consoleError = vi.spyOn(console, 'error');
let root: ReturnType<typeof hydrateRoot>;
await act(async () => { root = hydrateRoot(container, tree, { onRecoverableError: errors }); });
expect(renders).toEqual([false, true]);
expect(container.querySelector('input[switch]')).not.toBeNull();
expect(errors).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
await act(async () => root!.unmount());
container.remove();
});
Expand Down