From 21d67da9a929d73c958f03d67314e2340a7d2154 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 31 Jul 2026 20:38:33 +0300 Subject: [PATCH] match lex errors with instanceof, drop getErrorName `getErrorName` erased the typed error classes by flattening everything to a string code. Anchor on the classes instead: `LexAuthFactorError` for the login 2FA branch, `XrpcResponseError` for the signup error codes. `@atproto/lex-client` stays a direct dependency because `XrpcResponseError` is used at runtime for `instanceof`. It is already in the runtime graph via `@atproto/lex-password-session`, so the direct declaration adds no bundle weight; it makes the import legal under pnpm's strict layout and pins the version so both packages share one `LexError` identity. Co-Authored-By: Claude Fable 5 --- src/lib/__tests__/lex-error-test.ts | 54 ----------------------------- src/lib/lex-error.ts | 13 ------- src/screens/Login/LoginForm.tsx | 9 +++-- src/screens/Signup/state.ts | 29 ++++++++-------- 4 files changed, 18 insertions(+), 87 deletions(-) delete mode 100644 src/lib/__tests__/lex-error-test.ts delete mode 100644 src/lib/lex-error.ts diff --git a/src/lib/__tests__/lex-error-test.ts b/src/lib/__tests__/lex-error-test.ts deleted file mode 100644 index 4178049f39..0000000000 --- a/src/lib/__tests__/lex-error-test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {XrpcResponseError} from '@atproto/lex-client' -import {LexAuthFactorError} from '@atproto/lex-password-session' -import {describe, expect, it} from '@jest/globals' - -import {getErrorName} from '../lex-error' - -/** - * A stand-in for the method schema an `XrpcResponseError` is built against. - * The generated lexicons are not available yet, and `XrpcResponseError` only - * reads `method` back out for `matchesSchemaErrors()`, which these tests never - * call - so a minimal object is enough. - */ -const method = { - nsid: 'com.atproto.server.createSession', - type: 'procedure', - errors: ['AuthFactorTokenRequired'], -} as unknown as ConstructorParameters[0] - -/** An error as the lex client builds one from a JSON error response body. */ -function xrpcResponseError(error: string, message: string) { - return new XrpcResponseError(method, new Response(null, {status: 400}), { - encoding: 'application/json', - body: {error, message}, - }) -} - -describe('getErrorName', () => { - it('returns the lexicon error code of an XRPC response error', () => { - const e = xrpcResponseError('InvalidToken', 'Bad token scope') - expect(getErrorName(e)).toBe('InvalidToken') - }) - - it('returns AuthFactorTokenRequired for a LexAuthFactorError', () => { - /* - * The 2FA case: `PasswordSession.login` throws this, and it extends - * `LexError` WITHOUT being an `XrpcError` - the reason `getErrorName` is - * gated on `LexError`. - */ - const cause = xrpcResponseError( - 'AuthFactorTokenRequired', - 'A sign in code has been sent to your email address', - ) - expect(getErrorName(new LexAuthFactorError(cause))).toBe( - 'AuthFactorTokenRequired', - ) - }) - - it('returns undefined for non-lex errors', () => { - expect(getErrorName(new Error('InvalidToken'))).toBeUndefined() - expect(getErrorName('InvalidToken')).toBeUndefined() - expect(getErrorName(null)).toBeUndefined() - expect(getErrorName(undefined)).toBeUndefined() - }) -}) diff --git a/src/lib/lex-error.ts b/src/lib/lex-error.ts deleted file mode 100644 index 1ea37c5577..0000000000 --- a/src/lib/lex-error.ts +++ /dev/null @@ -1,13 +0,0 @@ -import {LexError} from '@atproto/lex-client' - -/** - * The lexicon error code (`err.error`). Gated on `LexError` (the base of the - * lex error hierarchy) rather than `XrpcError` so sibling `LexError` subclasses - * that are NOT `XrpcError` also surface their `.error` - notably - * `LexAuthFactorError` (`'AuthFactorTokenRequired'`), which `PasswordSession` - * throws for email-2FA logins. Every `XrpcError` is a `LexError`, so gating on - * the base covers server error responses too. - */ -export function getErrorName(e: unknown): string | undefined { - return e instanceof LexError ? e.error : undefined -} diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx index 025450bb71..ad50179bee 100644 --- a/src/screens/Login/LoginForm.tsx +++ b/src/screens/Login/LoginForm.tsx @@ -1,10 +1,10 @@ import {useRef, useState} from 'react' import {Keyboard, type TextInput, View} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' +import {LexAuthFactorError} from '@atproto/lex-password-session' import {Trans, useLingui} from '@lingui/react/macro' import {DEFAULT_SERVICE, HITSLOP_10, HITSLOP_20} from '#/lib/constants' -import {getErrorName} from '#/lib/lex-error' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' @@ -141,11 +141,10 @@ export const LoginForm = ({ const errMsg = String(err) setIsProcessing(false) /* - * Matches a `LexAuthFactorError` from `PasswordSession.login`, which is - * NOT an `XrpcError` - so `getErrorName`, gated on `LexError`, is - * required here. + * `LexAuthFactorError` is what `PasswordSession.login` throws when the + * server demands an email 2FA token. */ - if (getErrorName(err) === 'AuthFactorTokenRequired') { + if (err instanceof LexAuthFactorError) { setIsAuthFactorTokenNeeded(true) } else { onAttemptFailed() diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index 1ea3f1f7cf..762597e5c8 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -1,11 +1,11 @@ import {createContext, useCallback, useContext} from 'react' import {LayoutAnimation} from 'react-native' import {type ComAtprotoServerDescribeServer} from '@atproto/api' +import {XrpcResponseError} from '@atproto/lex-client' import {useLingui} from '@lingui/react/macro' import * as EmailValidator from 'email-validator' import {DEFAULT_SERVICE} from '#/lib/constants' -import {getErrorName} from '#/lib/lex-error' import {cleanError, isNetworkError} from '#/lib/strings/errors' import {createFullHandle} from '#/lib/strings/handles' import {getAge} from '#/lib/strings/time' @@ -259,20 +259,19 @@ export const useSignupContext = () => useContext(SignupContext) */ function classifyExpectedSignupError(e: unknown): string | undefined { /* - * `createAccount` now goes through `PasswordSession`, so these arrive as lex - * error codes rather than the generated `@atproto/api` error classes. - * Comparing the code as a plain string means a typo silently never matches; a - * typed variant constrained to the errors the lexicon declares arrives with - * the lexicon codegen in a later PR. + * TODO: `XrpcResponseError.error` is the open `LexErrorCode` union, so these + * codes are compared as plain strings and a typo silently never matches. + * Narrowing to the errors `com.atproto.server.createAccount` declares needs + * the generated lexicons. */ - const name = getErrorName(e) - if ( - name === 'InvalidHandle' || - name === 'HandleNotAvailable' || - name === 'InvalidPassword' || - name === 'UnsupportedDomain' - ) { - return name + if (e instanceof XrpcResponseError) { + switch (e.error) { + case 'InvalidHandle': + case 'HandleNotAvailable': + case 'InvalidPassword': + case 'UnsupportedDomain': + return e.error + } } /* the server sends no typed error for this case */ if (String(e).includes('Email already taken')) return 'EmailTaken' @@ -363,7 +362,7 @@ export function useSubmitSignup() { } catch (err) { const e = err as Error let errMsg = e.toString() - if (getErrorName(e) === 'InvalidInviteCode') { + if (e instanceof XrpcResponseError && e.error === 'InvalidInviteCode') { dispatch({ type: 'setError', value: l`Invite code not accepted. Check that you input it correctly and try again.`,