make until error types accurate

This commit is contained in:
Samuel Newman
2026-08-28 14:07:28 +01:00
parent 7de7c2cb72
commit b86c7b6782
15 changed files with 92 additions and 45 deletions
@@ -102,7 +102,7 @@ export function CreateListFromStarterPackDialog({
await until( await until(
5, 5,
1e3, 1e3,
(res: {items: unknown[]}) => res.items.length > 0, res => !!res?.items.length,
() => () =>
appviewClient.call(app.bsky.graph.getList, { appviewClient.call(app.bsky.graph.getList, {
list: listUri as AtUriString, list: listUri as AtUriString,
+20 -7
View File
@@ -3,27 +3,40 @@ import {describe, expect, it, jest} from '@jest/globals'
import {until} from './until' import {until} from './until'
describe('until', () => { describe('until', () => {
it('does not invoke the condition when an attempt rejects', async () => { it('passes attempt errors to the condition', async () => {
const error = new Error('failed')
const fn = jest const fn = jest
.fn<() => Promise<string>>() .fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error('failed')) .mockRejectedValueOnce(error)
.mockResolvedValue('ready') .mockResolvedValue('ready')
const cond = jest.fn((value: string) => value === 'ready') const cond = jest.fn((value: string | undefined) => value === 'ready')
await expect(until(2, 0, cond, fn)).resolves.toBe(true) await expect(until(2, 0, cond, fn)).resolves.toBe(true)
expect(fn).toHaveBeenCalledTimes(2) expect(fn).toHaveBeenCalledTimes(2)
expect(cond).toHaveBeenCalledTimes(1) expect(cond).toHaveBeenNthCalledWith(1, undefined, error)
expect(cond).toHaveBeenCalledWith('ready') expect(cond).toHaveBeenNthCalledWith(2, 'ready', undefined)
}) })
it('returns false when every attempt rejects', async () => { it('returns false when every attempt rejects', async () => {
const fn = jest const fn = jest
.fn<() => Promise<string>>() .fn<() => Promise<string>>()
.mockRejectedValue(new Error('failed')) .mockRejectedValue(new Error('failed'))
const cond = jest.fn((_value: string) => true) const cond = jest.fn((_value: string | undefined) => false)
await expect(until(2, 0, cond, fn)).resolves.toBe(false) await expect(until(2, 0, cond, fn)).resolves.toBe(false)
expect(fn).toHaveBeenCalledTimes(2) expect(fn).toHaveBeenCalledTimes(2)
expect(cond).not.toHaveBeenCalled() expect(cond).toHaveBeenCalledTimes(2)
})
it('can stop when an attempt rejects', async () => {
const error = new Error('failed')
const fn = jest.fn<() => Promise<string>>().mockRejectedValue(error)
const cond = jest.fn(
(_value: string | undefined, err: unknown) => err === error,
)
await expect(until(2, 0, cond, fn)).resolves.toBe(true)
expect(fn).toHaveBeenCalledTimes(1)
expect(cond).toHaveBeenCalledWith(undefined, error)
}) })
}) })
+12 -10
View File
@@ -1,22 +1,24 @@
import {timeout} from './timeout' import {timeout} from './timeout'
/**
* Retries an async operation until its result or error matches `cond`.
*/
export async function until<T>( export async function until<T>(
retries: number, retries: number,
delay: number, delay: number,
cond: (v: T) => boolean, cond: (v: T | undefined, err: unknown) => boolean,
fn: () => Promise<T>, fn: () => Promise<T>,
): Promise<boolean> { ): Promise<boolean> {
while (retries > 0) { while (retries > 0) {
let v: T
try { try {
v = await fn() const v = await fn()
} catch { if (cond(v, undefined)) {
await timeout(delay) return true
retries-- }
continue } catch (err) {
} if (cond(undefined, err)) {
if (cond(v)) { return true
return true }
} }
await timeout(delay) await timeout(delay)
retries-- retries--
+4 -1
View File
@@ -141,7 +141,10 @@ function createListItem({
async function whenAppViewReady( async function whenAppViewReady(
client: Client, client: Client,
uri: string, uri: string,
fn: (res?: app.bsky.graph.getStarterPack.$OutputBody) => boolean, fn: (
res: app.bsky.graph.getStarterPack.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
+5 -2
View File
@@ -43,7 +43,7 @@ export async function bulkWriteFollows(
writes: chunk, writes: chunk,
}) })
} }
await whenFollowsIndexed(appviewClient, did, res => !!res.follows.length) await whenFollowsIndexed(appviewClient, did, res => !!res?.follows.length)
const followUris = new Map<string, string>() const followUris = new Map<string, string>()
for (const r of followWrites) { for (const r of followWrites) {
@@ -58,7 +58,10 @@ export async function bulkWriteFollows(
async function whenFollowsIndexed( async function whenFollowsIndexed(
appviewClient: Client, appviewClient: Client,
actor: string, actor: string,
fn: (res: app.bsky.graph.getFollows.$OutputBody) => boolean, fn: (
res: app.bsky.graph.getFollows.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
+10 -3
View File
@@ -137,7 +137,11 @@ function GermSelfButton({did}: {did: string}) {
rkey: 'self', rkey: 'self',
}) })
await whenAppViewReady(appviewClient, did, res => !res.associated?.germ) await whenAppViewReady(
appviewClient,
did,
res => !!res && !res.associated?.germ,
)
return previousRecord return previousRecord
}, },
@@ -154,7 +158,7 @@ function GermSelfButton({did}: {did: string}) {
await whenAppViewReady( await whenAppViewReady(
appviewClient, appviewClient,
did, did,
res => !!res.associated?.germ, res => !!res?.associated?.germ,
) )
await queryClient.refetchQueries({queryKey: RQKEY(did)}) await queryClient.refetchQueries({queryKey: RQKEY(did)})
@@ -326,7 +330,10 @@ function platform() {
async function whenAppViewReady( async function whenAppViewReady(
appviewClient: Client, appviewClient: Client,
actor: string, actor: string,
fn: (res: app.bsky.actor.getProfile.$OutputBody) => boolean, fn: (
res: app.bsky.actor.getProfile.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
@@ -80,6 +80,7 @@ export function AutomationLabelSettingsScreen({}: Props) {
return existing return existing
}, },
checkCommitted: profile => { checkCommitted: profile => {
if (!profile) return false
const exists = !!profile.labels?.some(l => l.val === 'bot') const exists = !!profile.labels?.some(l => l.val === 'bot')
return exists === wasAdded return exists === wasAdded
}, },
@@ -70,6 +70,7 @@ export function PwiOptOut() {
return existing return existing
}, },
checkCommitted: profile => { checkCommitted: profile => {
if (!profile) return false
const exists = !!profile.labels?.some( const exists = !!profile.labels?.some(
l => l.val === '!no-unauthenticated', l => l.val === '!no-unauthenticated',
) )
+9 -10
View File
@@ -152,9 +152,9 @@ export function useListMetadataMutation() {
// wait for the appview to update // wait for the appview to update
await whenAppViewReady(appviewClient, res.uri, v => { await whenAppViewReady(appviewClient, res.uri, v => {
const list = v.list const list = v?.list
return ( return (
list.name === record.name && list.description === record.description list?.name === record.name && list.description === record.description
) )
}) })
return res return res
@@ -228,14 +228,10 @@ export function useListDeleteMutation() {
} }
/* /*
* Wait for the appview to update. Once the list is deleted `getList` * Once the deletion is indexed, `getList` throws and `until` passes the
* throws, `until` catches it and passes `undefined` here, so an absent * error to this predicate with an undefined response.
* body signals a completed delete - the old check read `!v.success` on
* the legacy response envelope, which lex does not expose.
*/ */
await whenAppViewReady(appviewClient, uri, v => { await whenAppViewReady(appviewClient, uri, v => !v)
return !v
})
}, },
onSuccess() { onSuccess() {
invalidateMyLists(queryClient) invalidateMyLists(queryClient)
@@ -299,7 +295,10 @@ export function useListBlockMutation() {
async function whenAppViewReady( async function whenAppViewReady(
client: Client, client: Client,
uri: string, uri: string,
fn: (res: app.bsky.graph.getList.$OutputBody) => boolean, fn: (
res: app.bsky.graph.getList.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
+5 -3
View File
@@ -79,10 +79,12 @@ export function usePinnedPostMutation() {
: undefined : undefined
return existing return existing
}, },
checkCommitted: profile => checkCommitted: profile => {
pinCurrentPost if (!profile) return false
return pinCurrentPost
? profile.pinnedPost?.uri === postUri ? profile.pinnedPost?.uri === postUri
: !profile.pinnedPost, : !profile.pinnedPost
},
}) })
if (pinCurrentPost) { if (pinCurrentPost) {
+9 -2
View File
@@ -146,7 +146,10 @@ interface ProfileUpdateParams {
) => Un$Typed<app.bsky.actor.profile.Main>) ) => Un$Typed<app.bsky.actor.profile.Main>)
newUserAvatar?: ImageMeta | undefined | null newUserAvatar?: ImageMeta | undefined | null
newUserBanner?: ImageMeta | undefined | null newUserBanner?: ImageMeta | undefined | null
checkCommitted?: (profile: app.bsky.actor.getProfile.$OutputBody) => boolean checkCommitted?: (
profile: app.bsky.actor.getProfile.$OutputBody | undefined,
err: unknown,
) => boolean
} }
export function useProfileUpdateMutation() { export function useProfileUpdateMutation() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -207,6 +210,7 @@ export function useProfileUpdateMutation() {
profile.did, profile.did,
checkCommitted || checkCommitted ||
(fresh => { (fresh => {
if (!fresh) return false
if (typeof newUserAvatar !== 'undefined') { if (typeof newUserAvatar !== 'undefined') {
if (newUserAvatar === null && fresh.avatar) { if (newUserAvatar === null && fresh.avatar) {
// url hasn't cleared yet // url hasn't cleared yet
@@ -678,7 +682,10 @@ function useProfileUnblockMutation() {
async function whenAppViewReady( async function whenAppViewReady(
client: Client, client: Client,
actor: string, actor: string,
fn: (profile: app.bsky.actor.getProfile.$OutputBody) => boolean, fn: (
profile: app.bsky.actor.getProfile.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
+10 -4
View File
@@ -325,9 +325,12 @@ export function useDeleteStarterPackMutation({
}) })
if (uri) { if (uri) {
await whenAppViewReady(appviewClient, uri, v => { /* Once the deletion is indexed, `getStarterPack` throws. */
return Boolean(v?.starterPack) === false await whenAppViewReady(
}) appviewClient,
uri,
v => Boolean(v?.starterPack) === false,
)
} }
if (listUri) { if (listUri) {
@@ -353,7 +356,10 @@ export function useDeleteStarterPackMutation({
async function whenAppViewReady( async function whenAppViewReady(
client: Client, client: Client,
uri: string, uri: string,
fn: (res?: app.bsky.graph.getStarterPack.$OutputBody) => boolean, fn: (
res: app.bsky.graph.getStarterPack.$OutputBody | undefined,
err: unknown,
) => boolean,
) { ) {
await until( await until(
5, // 5 tries 5, // 5 tries
@@ -31,7 +31,8 @@ export function useVerificationCreateMutation() {
await until( await until(
5, 5,
1e3, 1e3,
(profile: app.bsky.actor.getProfile.$OutputBody) => { profile => {
if (!profile) return false
if ( if (
profile.verification && profile.verification &&
profile.verification.verifications.find(v => v.uri === uri) profile.verification.verifications.find(v => v.uri === uri)
@@ -40,7 +40,8 @@ export function useVerificationsRemoveMutation() {
await until( await until(
5, 5,
1e3, 1e3,
(profile: app.bsky.actor.getProfile.$OutputBody) => { profile => {
if (!profile) return false
if ( if (
!profile.verification?.verifications.some(v => uris.includes(v.uri)) !profile.verification?.verifications.some(v => uris.includes(v.uri))
) { ) {
+1
View File
@@ -2482,6 +2482,7 @@ async function whenAppViewReady(
uri: string, uri: string,
fn: ( fn: (
res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined, res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined,
err: unknown,
) => boolean, ) => boolean,
) { ) {
await until( await until(