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 {String(useIOSNativeHaptics())}; } - expect(renderToString()).toContain('false'); - const tree = ; + const renders: boolean[] = []; + function HookOutput() { + const native = useIOSNativeHaptics(); + renders.push(native); + return {String(native)}; + } 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(); });