Files
2026-05-27 23:29:53 +01:00

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 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

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

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 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.