7.6 KiB
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 <PasskeyAutofillField> 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 <input> 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
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. |
<input> blur (via <PasskeyAutofillField>) |
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 <input autocomplete="username webauthn"> 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 anynavigatoraccess.isConditionalMediationAvailable()is called insideuseEffect(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
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.getstub at the jsdom layer — install a fakePublicKeyCredentialwithisConditionalMediationAvailableand anavigator.credentials.getthat resolves a fixtureAuthenticateResultor rejects with a chosenDOMException. Tests live insrc/use-passkey-autofill/use-passkey-autofill.test.tsxusing@suaveplan/testing-domrender utilities and@suaveplan/testing/runnerfor the runner-neutral imports (CLAUDE.md rule 11). - For environments lacking
PublicKeyCredentialentirely (deliberately stripped), assertREACT_AUTH.AUTOFILL_UNAVAILABLEis reported viaonErrorandreadystaysfalse. - 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
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
<PasskeyAutofillField>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
usePasskeyAutofillinstances 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 underlyingWEBAUTHN.*cause is preserved onerror.causefor introspection. onCredentialis required,onErroris optional. A consumer that does not passonErroraccepts that errors silently no-op (witherrorstill populated on the return value).- Stable
abortreference. Returned viauseCallbackso consumers can put it in dependency arrays without thrashing.