# Design — `usePasskeyAutofill` (`@suaveplan/react-auth`) Non-normative. Architecture, lifecycle, and rationale. The normative contract lives in `specs/react-auth/spec.md`. ## Package shape `@suaveplan/react-auth` is created by this change. It is a lightweight browser-tier package at `packages/auth/react-auth/`. Tier is `browser` (React 19 peer dep, DOM types fine). It holds exactly this hook and the optional `` wrapper today; future auth-related React hooks land in follow-up changes under the same package. The hook lives at `src/use-passkey-autofill/use-passkey-autofill.ts`; the component at `src/passkey-autofill-field/passkey-autofill-field.tsx`; errors at `src/errors/errors.ts`. Each has a sibling `.md`, a `.test.ts` (or `.test.tsx`), and stories per CLAUDE.md. ## Conditional UI background WebAuthn Level 3 §5.1.4 defines `mediation: "conditional"`. Browsers that support it surface available platform credentials in the autocomplete sheet of any `` with `autocomplete="username webauthn"`. The feature is gated on `PublicKeyCredential.isConditionalMediationAvailable()` — a static method that does not exist on Safari < 17, Firefox < 119, or any SSR environment. Calling `navigator.credentials.get({ publicKey, mediation: "conditional" })` without that check throws `NotSupportedError` on older browsers, which `@suaveplan/webauthn` maps to `WEBAUTHN.NOT_SUPPORTED`. The hook MUST therefore feature-detect before dispatching. Because feature detection is async and DOM-only, it can only run inside a `useEffect`. ## Hook signature ```ts interface UsePasskeyAutofillOptions { rpId: string; challenge: Uint8Array; onCredential: (result: AuthenticateResult) => void; onError?: (error: SuaveplanError) => void; signal?: AbortSignal; allowCredentials?: PublicKeyCredentialDescriptor[]; userVerification?: UserVerificationRequirement; timeout?: number; expectedOrigin?: string; expectedRpIdHash?: Uint8Array; } interface UsePasskeyAutofillReturn { ready: boolean; error: SuaveplanError | null; abort: () => void; } function usePasskeyAutofill(opts: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn; ``` `ready` flips to `true` after `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` Promise is in-flight. `error` carries the most recent translated error (cleared when a new attempt starts). `abort` is a stable `useCallback` that fires the internal `AbortController`. ## Lifecycle — AbortController ownership The hook owns exactly one `AbortController` per "session" (one mount, one option-identity). The controller is composed with the caller's `options.signal` via a small `composeSignals(...signals)` helper (any aborts → composed aborts). | Event | Behavior | |---|---| | Mount (client) | Run feature detect → dispatch `authenticate({ mediation: "conditional", signal: composed })`. | | Mount (server) | No-op. The `useEffect` never runs. | | Unmount | Call `controller.abort()`. The underlying `navigator.credentials.get` rejects with `AbortError`, mapped to `WEBAUTHN.ABORTED` → `REACT_AUTH.ABORTED`. | | Caller `signal` aborts | Composed controller aborts → same as unmount path. | | Caller calls returned `abort()` | Same as above. State remains mounted; consumer may re-trigger by changing option identity. | | `` blur (via ``) | Consumer-controlled. The component calls `abort()` on `onBlur` IF no credential has been delivered yet. The hook alone does not subscribe to blur (it does not touch the DOM). | | Option identity changes (e.g. fresh `challenge`) | Effect re-runs: old controller aborts, new controller created, new request dispatched. | | `onCredential` fires | Controller is left intact (the request is already settled); `error` cleared. | | `onError` fires | Controller aborted; `error` stored; consumer may remount or change challenge to retry. | The hook MUST NOT touch the DOM directly — it works alongside an `` rendered by the consumer. The browser surfaces the autofill UI based on the autocomplete token; the hook just keeps the conditional `authenticate` request pending so the browser has something to deliver into. ## SSR safety - `typeof window === "undefined"` short-circuits before any `navigator` access. - `isConditionalMediationAvailable()` is called inside `useEffect` (which never runs server-side under React 19 SSR). - The hook returns `{ ready: false, error: null, abort: noop }` on the server render, then re-renders on hydration. ## Error translation ```text WEBAUTHN.NOT_SUPPORTED → REACT_AUTH.AUTOFILL_UNAVAILABLE WEBAUTHN.ABORTED → REACT_AUTH.ABORTED all other WEBAUTHN.* → REACT_AUTH.AUTOFILL_FAILED (cause-chained) Zod schema rejection at entry → REACT_AUTH.INVALID_OPTIONS (thrown synchronously) SSR / non-DOM environment → REACT_AUTH.SSR_NO_OP (surfaced via onError only) ``` Every translation preserves the original `SuaveplanError` via `cause`, so consumers can introspect `error.cause?.code` for the underlying `WEBAUTHN.*` code. ## Testing approach - Real `navigator.credentials.get` stub at the jsdom layer — install a fake `PublicKeyCredential` with `isConditionalMediationAvailable` and a `navigator.credentials.get` that resolves a fixture `AuthenticateResult` or rejects with a chosen `DOMException`. Tests live in `src/use-passkey-autofill/use-passkey-autofill.test.tsx` using `@suaveplan/testing-dom` render utilities and `@suaveplan/testing/runner` for the runner-neutral imports (CLAUDE.md rule 11). - For environments lacking `PublicKeyCredential` entirely (deliberately stripped), assert `REACT_AUTH.AUTOFILL_UNAVAILABLE` is reported via `onError` and `ready` stays `false`. - Lifecycle is verified by mounting, asserting an in-flight request, unmounting, and asserting the request rejected with `REACT_AUTH.ABORTED`. - 100% coverage per CLAUDE.md / openspec rule 7 — no carve-outs. ## File layout ```text packages/auth/react-auth/ README.md package.json tsconfig.json vitest.config.ts src/ index.ts # explicit named re-exports (rule 6a) use-passkey-autofill/ index.ts use-passkey-autofill.ts use-passkey-autofill.md use-passkey-autofill.test.tsx use-passkey-autofill.stories.tsx use-passkey-autofill.types.ts use-passkey-autofill.schemas.ts passkey-autofill-field/ index.ts passkey-autofill-field.tsx passkey-autofill-field.md passkey-autofill-field.test.tsx passkey-autofill-field.stories.tsx passkey-autofill-field.types.ts errors/ index.ts errors.ts errors.md errors.test.ts utils/ compose-signals.ts compose-signals.md compose-signals.test.ts ``` ## Decisions - **Hook-first, component-optional.** The `` wrapper exists for the common case but is never a requirement; advanced consumers wire the hook to their own input. - **No global state, no context provider.** Multiple `usePasskeyAutofill` instances may coexist (e.g. a multi-tenant login page); each owns its own controller. - **Errors are translated, not leaked.** Consumers see `REACT_AUTH.*` codes. The underlying `WEBAUTHN.*` cause is preserved on `error.cause` for introspection. - **`onCredential` is required, `onError` is optional.** A consumer that does not pass `onError` accepts that errors silently no-op (with `error` still populated on the return value). - **Stable `abort` reference.** Returned via `useCallback` so consumers can put it in dependency arrays without thrashing.