diff --git a/apps/docs/content/docs/api.mdx b/apps/docs/content/docs/api.mdx
index 254366e..2eb9d29 100644
--- a/apps/docs/content/docs/api.mdx
+++ b/apps/docs/content/docs/api.mdx
@@ -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
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index f716638..98f5000 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -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.
diff --git a/packages/haptic/CHANGELOG.md b/packages/haptic/CHANGELOG.md
index a76080b..bcdb413 100644
--- a/packages/haptic/CHANGELOG.md
+++ b/packages/haptic/CHANGELOG.md
@@ -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
diff --git a/packages/haptic/README.md b/packages/haptic/README.md
index c6a2ff2..e319f13 100644
--- a/packages/haptic/README.md
+++ b/packages/haptic/README.md
@@ -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
diff --git a/packages/haptic/src/platform.ts b/packages/haptic/src/platform.ts
index d8fbda6..031f316 100644
--- a/packages/haptic/src/platform.ts
+++ b/packages/haptic/src/platform.ts
@@ -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(
@@ -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);
}
diff --git a/packages/haptic/test/platform.test.tsx b/packages/haptic/test/platform.test.tsx
index fe2552b..5f67401 100644
--- a/packages/haptic/test/platform.test.tsx
+++ b/packages/haptic/test/platform.test.tsx
@@ -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';
@@ -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 ; }
- expect(renderToString()).toContain('false');
- const tree = ;
+ const renders: boolean[] = [];
+ function HookOutput() {
+ const native = useIOSNativeHaptics();
+ renders.push(native);
+ return ;
+ }
const container = document.createElement('div');
- container.innerHTML = renderToString(tree);
+ const root = createRoot(container);
+ await act(async () => root.render());
+ 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 ;
+ }
+ const tree = ;
+ // 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('');
+ 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;
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();
});