diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 06b2fb3c82..9315e29b97 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -549,11 +549,6 @@ "count": 2 } }, - "src/lib/async/until.ts": { - "typescript/no-explicit-any": { - "count": 2 - } - }, "src/lib/broadcast/stub.ts": { "typescript/no-explicit-any": { "count": 1 diff --git a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx index 0a528adedb..b36cccf798 100644 --- a/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx +++ b/src/components/dialogs/lists/CreateListFromStarterPackDialog.tsx @@ -102,7 +102,7 @@ export function CreateListFromStarterPackDialog({ await until( 5, 1e3, - (res: {items: unknown[]}) => res.items.length > 0, + res => !!res?.items.length, () => appviewClient.call(app.bsky.graph.getList, { list: listUri as AtUriString, diff --git a/src/lib/async/until.test.ts b/src/lib/async/until.test.ts new file mode 100644 index 0000000000..d16ab9ef05 --- /dev/null +++ b/src/lib/async/until.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, it, jest} from '@jest/globals' + +import {until} from './until' + +describe('until', () => { + it('passes attempt errors to the condition', async () => { + const error = new Error('failed') + const fn = jest + .fn<() => Promise>() + .mockRejectedValueOnce(error) + .mockResolvedValue('ready') + const cond = jest.fn((value: string | undefined) => value === 'ready') + + await expect(until(2, 0, cond, fn)).resolves.toBe(true) + expect(fn).toHaveBeenCalledTimes(2) + expect(cond).toHaveBeenNthCalledWith(1, undefined, error) + expect(cond).toHaveBeenNthCalledWith(2, 'ready', undefined) + }) + + it('returns false when every attempt rejects', async () => { + const fn = jest + .fn<() => Promise>() + .mockRejectedValue(new Error('failed')) + const cond = jest.fn((_value: string | undefined) => false) + + await expect(until(2, 0, cond, fn)).resolves.toBe(false) + expect(fn).toHaveBeenCalledTimes(2) + expect(cond).toHaveBeenCalledTimes(2) + }) + + it('can stop when an attempt rejects', async () => { + const error = new Error('failed') + const fn = jest.fn<() => Promise>().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) + }) +}) diff --git a/src/lib/async/until.ts b/src/lib/async/until.ts index 1b7a576334..20a0ed04ef 100644 --- a/src/lib/async/until.ts +++ b/src/lib/async/until.ts @@ -1,9 +1,12 @@ import {timeout} from './timeout' +/** + * Retries an async operation until its result or error matches `cond`. + */ export async function until( retries: number, delay: number, - cond: (v: T, err: any) => boolean, + cond: (v: T | undefined, err: unknown) => boolean, fn: () => Promise, ): Promise { while (retries > 0) { @@ -12,10 +15,8 @@ export async function until( if (cond(v, undefined)) { return true } - } catch (e: any) { - // TODO: change the type signature of cond to accept undefined - // however this breaks every existing usage of until -sfn - if (cond(undefined as unknown as T, e)) { + } catch (err) { + if (cond(undefined, err)) { return true } } diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index a786fb47ad..3702f05d07 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -141,7 +141,10 @@ function createListItem({ async function whenAppViewReady( client: Client, uri: string, - fn: (res?: app.bsky.graph.getStarterPack.$OutputBody) => boolean, + fn: ( + res: app.bsky.graph.getStarterPack.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index 07ccc2f17a..c12f268ec9 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -43,7 +43,7 @@ export async function bulkWriteFollows( writes: chunk, }) } - await whenFollowsIndexed(appviewClient, did, res => !!res.follows.length) + await whenFollowsIndexed(appviewClient, did, res => !!res?.follows.length) const followUris = new Map() for (const r of followWrites) { @@ -58,7 +58,10 @@ export async function bulkWriteFollows( async function whenFollowsIndexed( appviewClient: Client, actor: string, - fn: (res: app.bsky.graph.getFollows.$OutputBody) => boolean, + fn: ( + res: app.bsky.graph.getFollows.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/screens/Profile/components/GermButton.tsx b/src/screens/Profile/components/GermButton.tsx index a3b561ae20..0a93fc50c6 100644 --- a/src/screens/Profile/components/GermButton.tsx +++ b/src/screens/Profile/components/GermButton.tsx @@ -137,7 +137,11 @@ function GermSelfButton({did}: {did: string}) { rkey: 'self', }) - await whenAppViewReady(appviewClient, did, res => !res.associated?.germ) + await whenAppViewReady( + appviewClient, + did, + res => !!res && !res.associated?.germ, + ) return previousRecord }, @@ -154,7 +158,7 @@ function GermSelfButton({did}: {did: string}) { await whenAppViewReady( appviewClient, did, - res => !!res.associated?.germ, + res => !!res?.associated?.germ, ) await queryClient.refetchQueries({queryKey: RQKEY(did)}) @@ -326,7 +330,10 @@ function platform() { async function whenAppViewReady( appviewClient: Client, actor: string, - fn: (res: app.bsky.actor.getProfile.$OutputBody) => boolean, + fn: ( + res: app.bsky.actor.getProfile.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/screens/Settings/AutomationLabelSettings.tsx b/src/screens/Settings/AutomationLabelSettings.tsx index 480f35a972..ee84f8ea54 100644 --- a/src/screens/Settings/AutomationLabelSettings.tsx +++ b/src/screens/Settings/AutomationLabelSettings.tsx @@ -80,6 +80,7 @@ export function AutomationLabelSettingsScreen({}: Props) { return existing }, checkCommitted: profile => { + if (!profile) return false const exists = !!profile.labels?.some(l => l.val === 'bot') return exists === wasAdded }, diff --git a/src/screens/Settings/components/PwiOptOut.tsx b/src/screens/Settings/components/PwiOptOut.tsx index 4b2edfb64b..7c81657246 100644 --- a/src/screens/Settings/components/PwiOptOut.tsx +++ b/src/screens/Settings/components/PwiOptOut.tsx @@ -70,6 +70,7 @@ export function PwiOptOut() { return existing }, checkCommitted: profile => { + if (!profile) return false const exists = !!profile.labels?.some( l => l.val === '!no-unauthenticated', ) diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index bd29d578dc..4ecd8fffbe 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -152,9 +152,9 @@ export function useListMetadataMutation() { // wait for the appview to update await whenAppViewReady(appviewClient, res.uri, v => { - const list = v.list + const list = v?.list return ( - list.name === record.name && list.description === record.description + list?.name === record.name && list.description === record.description ) }) return res @@ -228,14 +228,10 @@ export function useListDeleteMutation() { } /* - * Wait for the appview to update. Once the list is deleted `getList` - * throws, `until` catches it and passes `undefined` here, so an absent - * body signals a completed delete - the old check read `!v.success` on - * the legacy response envelope, which lex does not expose. + * Once the deletion is indexed, `getList` throws and `until` passes the + * error to this predicate with an undefined response. */ - await whenAppViewReady(appviewClient, uri, v => { - return !v - }) + await whenAppViewReady(appviewClient, uri, v => !v) }, onSuccess() { invalidateMyLists(queryClient) @@ -299,7 +295,10 @@ export function useListBlockMutation() { async function whenAppViewReady( client: Client, uri: string, - fn: (res: app.bsky.graph.getList.$OutputBody) => boolean, + fn: ( + res: app.bsky.graph.getList.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/state/queries/pinned-post.ts b/src/state/queries/pinned-post.ts index 6bb2b12c29..d743a9575c 100644 --- a/src/state/queries/pinned-post.ts +++ b/src/state/queries/pinned-post.ts @@ -79,10 +79,12 @@ export function usePinnedPostMutation() { : undefined return existing }, - checkCommitted: profile => - pinCurrentPost + checkCommitted: profile => { + if (!profile) return false + return pinCurrentPost ? profile.pinnedPost?.uri === postUri - : !profile.pinnedPost, + : !profile.pinnedPost + }, }) if (pinCurrentPost) { diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index dcfcba82bb..d93929c7b0 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -146,7 +146,10 @@ interface ProfileUpdateParams { ) => Un$Typed) newUserAvatar?: 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() { const queryClient = useQueryClient() @@ -207,6 +210,7 @@ export function useProfileUpdateMutation() { profile.did, checkCommitted || (fresh => { + if (!fresh) return false if (typeof newUserAvatar !== 'undefined') { if (newUserAvatar === null && fresh.avatar) { // url hasn't cleared yet @@ -678,7 +682,10 @@ function useProfileUnblockMutation() { async function whenAppViewReady( client: Client, actor: string, - fn: (profile: app.bsky.actor.getProfile.$OutputBody) => boolean, + fn: ( + profile: app.bsky.actor.getProfile.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 41b8dd65d9..76f8fd2e31 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -325,9 +325,12 @@ export function useDeleteStarterPackMutation({ }) if (uri) { - await whenAppViewReady(appviewClient, uri, v => { - return Boolean(v?.starterPack) === false - }) + /* Once the deletion is indexed, `getStarterPack` throws. */ + await whenAppViewReady( + appviewClient, + uri, + v => Boolean(v?.starterPack) === false, + ) } if (listUri) { @@ -353,7 +356,10 @@ export function useDeleteStarterPackMutation({ async function whenAppViewReady( client: Client, uri: string, - fn: (res?: app.bsky.graph.getStarterPack.$OutputBody) => boolean, + fn: ( + res: app.bsky.graph.getStarterPack.$OutputBody | undefined, + err: unknown, + ) => boolean, ) { await until( 5, // 5 tries diff --git a/src/state/queries/verification/useVerificationCreateMutation.tsx b/src/state/queries/verification/useVerificationCreateMutation.tsx index 50510bac9e..d4a64bad34 100644 --- a/src/state/queries/verification/useVerificationCreateMutation.tsx +++ b/src/state/queries/verification/useVerificationCreateMutation.tsx @@ -31,7 +31,8 @@ export function useVerificationCreateMutation() { await until( 5, 1e3, - (profile: app.bsky.actor.getProfile.$OutputBody) => { + profile => { + if (!profile) return false if ( profile.verification && profile.verification.verifications.find(v => v.uri === uri) diff --git a/src/state/queries/verification/useVerificationsRemoveMutation.tsx b/src/state/queries/verification/useVerificationsRemoveMutation.tsx index e0e0fe5e5a..e8a83e5ea8 100644 --- a/src/state/queries/verification/useVerificationsRemoveMutation.tsx +++ b/src/state/queries/verification/useVerificationsRemoveMutation.tsx @@ -40,7 +40,8 @@ export function useVerificationsRemoveMutation() { await until( 5, 1e3, - (profile: app.bsky.actor.getProfile.$OutputBody) => { + profile => { + if (!profile) return false if ( !profile.verification?.verifications.some(v => uris.includes(v.uri)) ) { diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 28f69300df..a1f1a4daf9 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -2482,6 +2482,7 @@ async function whenAppViewReady( uri: string, fn: ( res: app.bsky.unspecced.getPostThreadV2.$OutputBody | undefined, + err: unknown, ) => boolean, ) { await until(