add the canonical client hooks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-03 13:29:31 +03:00
parent 40521e4008
commit 9a649b97ad
3 changed files with 260 additions and 11 deletions
@@ -10,7 +10,7 @@ import {logger} from '#/logger'
import {getContentLanguages} from '#/state/preferences/languages'
import {STALE} from '#/state/queries'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useLexClient} from '#/state/session'
import {useAppviewClient} from '#/state/session'
import {app} from '#/lexicons'
export const DEFAULT_LIMIT = 5
@@ -33,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) =>
limit === undefined ? ['trends'] : ['trends', {limit}]
export function useGetTrendsQuery(props: QueryProps = {}) {
const client = useLexClient()
const client = useAppviewClient()
const {data: preferences} = usePreferencesQuery()
const limit = props.limit ?? DEFAULT_LIMIT
const mutedWords = useMemo(() => {
@@ -0,0 +1,193 @@
import {type Client} from '@atproto/lex'
import {PasswordSession} from '@atproto/lex-password-session'
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
import {act, render} from '@testing-library/react-native'
import {type SessionAccount} from '../types'
/*
* The provider pulls the whole app shell in through `#/state/util` and the
* account factories. These mocks cut the tree back to the session lifecycle
* itself, mirroring provider-abort-test.tsx.
*/
jest.mock('#/state/persisted', () => {
const {
defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return {
defaults,
get: (key: keyof typeof defaults) => defaults[key],
write: () => Promise.resolve(),
readLatest: (key: keyof typeof defaults) => defaults[key],
onUpdate: () => () => {},
}
})
jest.mock('#/state/util', () => ({useCloseAllActiveElements: () => () => {}}))
jest.mock('#/components/dialogs/Context', () => ({
useGlobalDialogsControlContext: () => ({signinDialogControl: {open() {}}}),
}))
jest.mock('#/analytics', () => ({
AnalyticsContext: ({children}: {children: React.ReactNode}) => children,
useAnalyticsBase: () => ({metric() {}, logger: {debug() {}, error() {}}}),
utils: {accountToSessionMetadata: () => ({}), useMeta: () => undefined},
}))
jest.mock('#/state/shell/onboarding', () => ({
useOnboardingDispatch: () => () => {},
}))
jest.mock('#/ageAssurance/data', () => ({
clearAgeAssuranceServerDataForAll: () => {},
clearAgeAssuranceServerDataForDid: () => {},
}))
jest.mock('#/lib/persisted-query-storage', () => ({
clearPersistedQueryStorage: () => Promise.resolve(),
}))
jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken: () => Promise.resolve(),
}))
jest.mock('jwt-decode', () => ({
jwtDecode: () => ({scope: 'com.atproto.access'}),
}))
jest.mock('#/state/events', () => ({
emitSessionDropped: () => {},
emitNetworkConfirmed: () => {},
emitNetworkLost: () => {},
}))
const mockLogin = jest.fn<(...args: unknown[]) => Promise<unknown>>()
jest.mock('../session-core', () => ({
...jest.requireActual<object>('../session-core'),
createSessionBundleAndLogin: (...args: unknown[]) => mockLogin(...args),
}))
jest.mock('../create-account', () => ({
createSessionBundleAndCreateAccount: () => new Promise(() => {}),
}))
import {
Provider,
useAppviewClient,
useChatClient,
useMaybeChatClient,
useMaybePdsClient,
usePdsClient,
useSessionApi,
} from '#/state/session'
import {type SessionApiContext} from '#/state/session/types'
import {BskyAppAgent, PasswordSessionManager} from '../bridge-agent'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
getUnauthenticatedThrowingClient,
} from '../clients'
import {type SessionBundle} from '../session-core'
import {sessionAccountToSessionData} from '../session-data'
import {asFetch, makeAccount, makeMockFetch} from './mock-fetch'
type Clients = {
appview: Client
pds: Client
chat: Client
maybePds: Client | null
maybeChat: Client | null
}
/**
* Build a bundle whose agent is a real `BskyAppAgent` over a real
* `PasswordSession`, since the client builders derive from the agent. Only the
* fields the provider reads are populated.
*/
function makeBundle(account: SessionAccount): SessionBundle {
const fetchMock = makeMockFetch()
const session = new PasswordSession(sessionAccountToSessionData(account), {
fetch: asFetch(fetchMock),
})
const manager = new PasswordSessionManager(session, {
service: account.service,
})
manager.setFetch(asFetch(fetchMock))
return {
session,
agent: new BskyAppAgent(manager),
service: new URL(account.service),
}
}
/** Render the provider and hand back the client hooks' current values. */
function renderClients(): {api: SessionApiContext; clients: () => Clients} {
let api!: SessionApiContext
let clients!: Clients
function Probe() {
api = useSessionApi()
clients = {
appview: useAppviewClient(),
pds: usePdsClient(),
chat: useChatClient(),
maybePds: useMaybePdsClient(),
maybeChat: useMaybeChatClient(),
}
return null
}
render(
<Provider>
<Probe />
</Provider>,
)
return {api, clients: () => clients}
}
beforeEach(() => {
mockLogin.mockReset()
})
describe('client hooks while logged out', () => {
it('serves the public agent for appview reads', () => {
const {clients} = renderClients()
expect(clients().appview).toBeDefined()
/* the logged-out bundle's agent IS the public agent, so no separate branch */
expect(clients().appview.did).toBeUndefined()
})
it('serves the throwing client for the write surfaces', () => {
const {clients} = renderClients()
const throwing = getUnauthenticatedThrowingClient()
expect(clients().pds).toBe(throwing)
expect(clients().chat).toBe(throwing)
})
it('serves null from the maybe variants', () => {
const {clients} = renderClients()
expect(clients().maybePds).toBeNull()
expect(clients().maybeChat).toBeNull()
})
})
describe('client hooks with a session', () => {
it('derives every surface from the session bundle agent', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {api, clients} = renderClients()
mockLogin.mockResolvedValueOnce({bundle, account})
await act(async () => {
await api.login({} as never, 'LoginForm')
})
expect(clients().appview).toBe(agentToAppviewClient(bundle.agent))
expect(clients().pds).toBe(agentToPdsClient(bundle.agent))
expect(clients().chat).toBe(agentToChatClient(bundle.agent))
})
it('serves the same clients from the maybe variants', async () => {
const account = makeAccount()
const bundle = makeBundle(account)
const {api, clients} = renderClients()
mockLogin.mockResolvedValueOnce({bundle, account})
await act(async () => {
await api.login({} as never, 'LoginForm')
})
expect(clients().maybePds).toBe(clients().pds)
expect(clients().maybeChat).toBe(clients().chat)
})
})
+65 -9
View File
@@ -19,7 +19,13 @@ import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
import {IS_WEB} from '#/env'
import {emitSessionDropped} from '../events'
import {agentToLexClient, getPublicLexClient} from './clients'
import {
agentToAppviewClient,
agentToChatClient,
agentToPdsClient,
getPublicAppviewClient,
getUnauthenticatedThrowingClient,
} from './clients'
import {createSessionBundleAndCreateAccount} from './create-account'
import {pickExpiryRescueCandidate} from './expiry-rescue'
import {type Action, getInitialState, reducer, type State} from './reducer'
@@ -711,20 +717,70 @@ export function useAgent(): AtpAgent {
}
/**
* The lex client for the active session, or for the public agent when logged
* out.
* Client for appview reads.
*
* When logged out the bundle's agent is the public agent built by
* `createPublicAgent`, which is configured with the appview proxy and dispatches
* unauthenticated, so the logged-out fallback is the agent itself - there is no
* separate public branch here.
*/
export function useLexClient(): Client {
export function useAppviewClient(): Client {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('useLexClient() must be below <SessionProvider>.')
throw Error('useAppviewClient() must be below <SessionProvider>.')
}
return agentToLexClient(bundle.agent)
return agentToAppviewClient(bundle.agent)
}
/**
* The unauthenticated lex client for public reads.
* Client for account-host requests. It shares the active session's auth
* lifecycle but sets no proxy or labeler headers, so calls target the PDS.
* Logged out, calls throw `NotAuthenticatedError` before network I/O. Use
* {@link useMaybePdsClient} when the caller must branch on authentication.
*/
export function usePublicLexClient(): Client {
return getPublicLexClient()
export function usePdsClient(): Client {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('usePdsClient() must be below <SessionProvider>.')
}
return bundle.session
? agentToPdsClient(bundle.agent)
: getUnauthenticatedThrowingClient()
}
/**
* Client for `chat.bsky.*` calls. Logged-out calls throw
* `NotAuthenticatedError`; use {@link useMaybeChatClient} to branch on auth.
*/
export function useChatClient(): Client {
const bundle = useContext(BundleContext)
if (!bundle) {
throw Error('useChatClient() must be below <SessionProvider>.')
}
return bundle.session
? agentToChatClient(bundle.agent)
: getUnauthenticatedThrowingClient()
}
/**
* Account-host client for the active session, or `null` when logged out.
*/
export function useMaybePdsClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? agentToPdsClient(bundle.agent) : null
}
/**
* Chat client for the active session, or `null` when logged out.
*/
export function useMaybeChatClient(): Client | null {
const bundle = useContext(BundleContext)
return bundle?.session ? agentToChatClient(bundle.agent) : null
}
/**
* The unauthenticated client for public appview reads.
*/
export function usePublicAppviewClient(): Client {
return getPublicAppviewClient()
}