diff --git a/__tests__/lib/async/bundle.test.ts b/__tests__/lib/async/bundle.test.ts new file mode 100644 index 0000000000..8718f54a60 --- /dev/null +++ b/__tests__/lib/async/bundle.test.ts @@ -0,0 +1,47 @@ +import {bundleAsync} from '../../../src/lib/async/bundle' + +describe('bundle', () => { + it('bundles multiple simultaneous calls into one execution', async () => { + let calls = 0 + const fn = bundleAsync(async () => { + calls++ + await new Promise(r => setTimeout(r, 1)) + return 'hello' + }) + const [res1, res2, res3] = await Promise.all([fn(), fn(), fn()]) + expect(calls).toEqual(1) + expect(res1).toEqual('hello') + expect(res2).toEqual('hello') + expect(res3).toEqual('hello') + }) + it('does not bundle non-simultaneous calls', async () => { + let calls = 0 + const fn = bundleAsync(async () => { + calls++ + await new Promise(r => setTimeout(r, 1)) + return 'hello' + }) + const res1 = await fn() + const res2 = await fn() + const res3 = await fn() + expect(calls).toEqual(3) + expect(res1).toEqual('hello') + expect(res2).toEqual('hello') + expect(res3).toEqual('hello') + }) + it('is not affected by rejections', async () => { + let calls = 0 + const fn = bundleAsync(async () => { + calls++ + await new Promise(r => setTimeout(r, 1)) + throw new Error() + }) + const res1 = await fn().catch(() => 'reject') + const res2 = await fn().catch(() => 'reject') + const res3 = await fn().catch(() => 'reject') + expect(calls).toEqual(3) + expect(res1).toEqual('reject') + expect(res2).toEqual('reject') + expect(res3).toEqual('reject') + }) +}) diff --git a/package.json b/package.json index 8790a213bd..ed0c23bd46 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@segment/analytics-react-native": "^2.10.1", "@segment/sovran-react-native": "^0.4.5", "@zxing/text-encoding": "^0.9.0", + "await-lock": "^2.2.2", "base64-js": "^1.5.1", "email-validator": "^2.0.4", "he": "^1.2.0", diff --git a/src/lib/async/bundle.ts b/src/lib/async/bundle.ts new file mode 100644 index 0000000000..e307cd4373 --- /dev/null +++ b/src/lib/async/bundle.ts @@ -0,0 +1,24 @@ +type BundledFn = ( + ...args: Args +) => Promise + +/** + * A helper which ensures that multiple calls to an async function + * only produces one in-flight request at a time. + */ +export function bundleAsync( + fn: BundledFn, +): BundledFn { + let promise: Promise | undefined + return async (...args) => { + if (promise) { + return promise + } + promise = fn(...args) + try { + return await promise + } finally { + promise = undefined + } + } +} diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts index 2477e13503..6d21ab753a 100644 --- a/src/state/models/feed-view.ts +++ b/src/state/models/feed-view.ts @@ -5,6 +5,8 @@ import { AppBskyFeedPost, AppBskyFeedGetAuthorFeed as GetAuthorFeed, } from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from '../../lib/async/bundle' type FeedViewPost = AppBskyFeedFeedViewPost.Main type ReasonRepost = AppBskyFeedFeedViewPost.ReasonRepost type PostView = AppBskyFeedPost.View @@ -188,10 +190,8 @@ export class FeedModel { loadMoreCursor: string | undefined pollCursor: string | undefined - private _loadPromise: Promise | undefined - private _loadMorePromise: Promise | undefined - private _loadLatestPromise: Promise | undefined - private _updatePromise: Promise | undefined + // used to linearize async modifications to state + private lock = new AwaitLock() // data feed: FeedItemModel[] = [] @@ -270,23 +270,26 @@ export class FeedModel { /** * Load for first render */ - async setup(isRefreshing = false) { + setup = bundleAsync(async (isRefreshing: boolean = false) => { this.rootStore.log.debug('FeedModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } - if (this._loadPromise) { - return this._loadPromise - } - await this._pendingWork() - this.setHasNewLatest(false) - this._loadPromise = this._initialLoad(isRefreshing) + await this.lock.acquireAsync() try { - await this._loadPromise + this.setHasNewLatest(false) + this._xLoading(isRefreshing) + try { + const res = await this._getFeed({limit: PAGE_SIZE}) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } } finally { - this._loadPromise = undefined + this.lock.release() } - } + }) /** * Register any event listeners. Returns a cleanup function. @@ -306,51 +309,93 @@ export class FeedModel { /** * Load more posts to the end of the feed */ - async loadMore() { - if (this._loadMorePromise) { - return this._loadMorePromise - } - await this._pendingWork() - this._loadMorePromise = this._loadMore() + loadMore = bundleAsync(async () => { + await this.lock.acquireAsync() try { - await this._loadMorePromise + if (!this.hasMore || this.hasError) { + return + } + this._xLoading() + try { + const res = await this._getFeed({ + before: this.loadMoreCursor, + limit: PAGE_SIZE, + }) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to load more', { + params: this.params, + e, + }) + } } finally { - this._loadMorePromise = undefined + this.lock.release() } - } + }) /** * Load more posts to the start of the feed */ - async loadLatest() { - if (this._loadLatestPromise) { - return this._loadLatestPromise - } - await this._pendingWork() - this.setHasNewLatest(false) - this._loadLatestPromise = this._loadLatest() + loadLatest = bundleAsync(async () => { + await this.lock.acquireAsync() try { - await this._loadLatestPromise + this.setHasNewLatest(false) + this._xLoading() + try { + const res = await this._getFeed({limit: PAGE_SIZE}) + await this._prependAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to load latest', { + params: this.params, + e, + }) + } } finally { - this._loadLatestPromise = undefined + this.lock.release() } - } + }) /** * Update content in-place */ - async update() { - if (this._updatePromise) { - return this._updatePromise - } - await this._pendingWork() - this._updatePromise = this._update() + update = bundleAsync(async () => { + await this.lock.acquireAsync() try { - await this._updatePromise + if (!this.feed.length) { + return + } + this._xLoading() + let numToFetch = this.feed.length + let cursor + try { + do { + const res: GetTimeline.Response = await this._getFeed({ + before: cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.feed.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.feed.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('FeedView: Failed to update', { + params: this.params, + e, + }) + } } finally { - this._updatePromise = undefined + this.lock.release() } - } + }) /** * Check if new posts are available @@ -359,7 +404,6 @@ export class FeedModel { if (this.hasNewLatest) { return } - await this._pendingWork() const res = await this._getFeed({limit: 1}) const currentLatestUri = this.pollCursor const receivedLatestUri = res.data.feed[0] @@ -404,101 +448,9 @@ export class FeedModel { } } - // loader functions + // helper functions // = - private async _pendingWork() { - if (this._loadPromise) { - await this._loadPromise - } - if (this._loadMorePromise) { - await this._loadMorePromise - } - if (this._loadLatestPromise) { - await this._loadLatestPromise - } - if (this._updatePromise) { - await this._updatePromise - } - } - - private async _initialLoad(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const res = await this._getFeed({limit: PAGE_SIZE}) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _loadLatest() { - this._xLoading() - try { - const res = await this._getFeed({limit: PAGE_SIZE}) - await this._prependAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to load latest', { - params: this.params, - e, - }) - } - } - - private async _loadMore() { - if (!this.hasMore || this.hasError) { - return - } - this._xLoading() - try { - const res = await this._getFeed({ - before: this.loadMoreCursor, - limit: PAGE_SIZE, - }) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to load more', { - params: this.params, - e, - }) - } - } - - private async _update() { - if (!this.feed.length) { - return - } - this._xLoading() - let numToFetch = this.feed.length - let cursor - try { - do { - const res: GetTimeline.Response = await this._getFeed({ - before: cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.feed.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.feed.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to update', { - params: this.params, - e, - }) - } - } - private async _replaceAll( res: GetTimeline.Response | GetAuthorFeed.Response, ) { diff --git a/src/state/models/notifications-view.ts b/src/state/models/notifications-view.ts index 91b5c83c67..7769cd1b47 100644 --- a/src/state/models/notifications-view.ts +++ b/src/state/models/notifications-view.ts @@ -8,6 +8,8 @@ import { AppBskyGraphAssertion, AppBskyGraphFollow, } from '@atproto/api' +import AwaitLock from 'await-lock' +import {bundleAsync} from '../../lib/async/bundle' import {RootStoreModel} from './root-store' import {PostThreadViewModel} from './post-thread-view' import {cleanError} from '../../lib/strings' @@ -191,9 +193,8 @@ export class NotificationsViewModel { hasMore = true loadMoreCursor?: string - private _loadPromise: Promise | undefined - private _loadMorePromise: Promise | undefined - private _updatePromise: Promise | undefined + // used to linearize async modifications to state + private lock = new AwaitLock() // data notifications: NotificationsViewItemModel[] = [] @@ -250,19 +251,28 @@ export class NotificationsViewModel { /** * Load for first render */ - async setup(isRefreshing = false) { + setup = bundleAsync(async (isRefreshing: boolean = false) => { this.rootStore.log.debug('NotificationsModel:setup', {isRefreshing}) if (isRefreshing) { this.isRefreshing = true // set optimistically for UI } - if (this._loadPromise) { - return this._loadPromise + await this.lock.acquireAsync() + try { + this._xLoading(isRefreshing) + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + }) + const res = await this.rootStore.api.app.bsky.notification.list(params) + await this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + } finally { + this.lock.release() } - await this._pendingWork() - this._loadPromise = this._initialLoad(isRefreshing) - await this._loadPromise - this._loadPromise = undefined - } + }) /** * Reset and load @@ -274,34 +284,71 @@ export class NotificationsViewModel { /** * Load more posts to the end of the notifications */ - async loadMore() { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async () => { + if (!this.hasMore) { + return } - await this._pendingWork() - this._loadMorePromise = this._loadMore() + this.lock.acquireAsync() try { - await this._loadMorePromise + this._xLoading() + try { + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.notification.list(params) + await this._appendAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to load more', { + params: this.params, + e, + }) + } } finally { - this._loadMorePromise = undefined + this.lock.release() } - } + }) /** * Update content in-place */ - async update() { - if (this._updatePromise) { - return this._updatePromise - } - await this._pendingWork() - this._updatePromise = this._update() + update = bundleAsync(async () => { + await this.lock.acquireAsync() try { - await this._updatePromise + if (!this.notifications.length) { + return + } + this._xLoading() + let numToFetch = this.notifications.length + let cursor + try { + do { + const res: ListNotifications.Response = + await this.rootStore.api.app.bsky.notification.list({ + before: cursor, + limit: Math.min(numToFetch, 100), + }) + if (res.data.notifications.length === 0) { + break // sanity check + } + this._updateAll(res) + numToFetch -= res.data.notifications.length + cursor = res.data.cursor + } while (cursor && numToFetch > 0) + this._xIdle() + } catch (e: any) { + this._xIdle() // don't bubble the error to the user + this.rootStore.log.error('NotificationsView: Failed to update', { + params: this.params, + e, + }) + } } finally { - this._updatePromise = undefined + this.lock.release() } - } + }) /** * Update read/unread state @@ -356,88 +403,9 @@ export class NotificationsViewModel { } } - // loader functions + // helper functions // = - private async _pendingWork() { - if (this._loadPromise) { - await this._loadPromise - } - if (this._loadMorePromise) { - await this._loadMorePromise - } - if (this._updatePromise) { - await this._updatePromise - } - } - - private async _initialLoad(isRefreshing = false) { - this._xLoading(isRefreshing) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - }) - const res = await this.rootStore.api.app.bsky.notification.list(params) - await this._replaceAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - - private async _loadMore() { - if (!this.hasMore) { - return - } - this._xLoading() - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: this.loadMoreCursor, - }) - const res = await this.rootStore.api.app.bsky.notification.list(params) - await this._appendAll(res) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to load more', { - params: this.params, - e, - }) - } - } - - private async _update() { - if (!this.notifications.length) { - return - } - this._xLoading() - let numToFetch = this.notifications.length - let cursor - try { - do { - const res: ListNotifications.Response = - await this.rootStore.api.app.bsky.notification.list({ - before: cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.notifications.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.notifications.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('NotificationsView: Failed to update', { - params: this.params, - e, - }) - } - } - private async _replaceAll(res: ListNotifications.Response) { if (res.data.notifications[0]) { this.mostRecentNotification = new NotificationsViewItemModel( diff --git a/src/state/models/reposted-by-view.ts b/src/state/models/reposted-by-view.ts index 9e92a3132a..54b9a67cd7 100644 --- a/src/state/models/reposted-by-view.ts +++ b/src/state/models/reposted-by-view.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AtUri} from '../../third-party/uri' import {AppBskyFeedGetRepostedBy as GetRepostedBy} from '@atproto/api' import {RootStoreModel} from './root-store' +import {bundleAsync} from '../../lib/async/bundle' import {cleanError} from '../../lib/strings' import * as apilib from '../lib/api' @@ -19,7 +20,6 @@ export class RepostedByViewModel { params: GetRepostedBy.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data uri: string = '' @@ -59,55 +59,7 @@ export class RepostedByViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise - } - this._loadMorePromise = this._load(isRefreshing) - try { - await this._loadMorePromise - } finally { - this._loadMorePromise = undefined - } - } - - // state transitions - // = - - private _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - private _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - this.rootStore.log.error('Failed to fetch reposted by view', err) - } - } - - // loader functions - // = - - private async _resolveUri() { - const urip = new AtUri(this.params.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - this.error = e.toString() - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - private async _load(replace = false) { + loadMore = bundleAsync(async (replace: boolean = false) => { this._xLoading(replace) try { if (!this.resolvedUri) { @@ -128,6 +80,42 @@ export class RepostedByViewModel { } catch (e: any) { this._xIdle(e) } + }) + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch reposted by view', err) + } + } + + // helper functions + // = + + private async _resolveUri() { + const urip = new AtUri(this.params.uri) + if (!urip.host.startsWith('did:')) { + try { + urip.host = await apilib.resolveName(this.rootStore, urip.host) + } catch (e: any) { + this.error = e.toString() + } + } + runInAction(() => { + this.resolvedUri = urip.toString() + }) } private _replaceAll(res: GetRepostedBy.Response) { diff --git a/src/state/models/suggested-actors-view.ts b/src/state/models/suggested-actors-view.ts index 12b70c1910..7f5104b985 100644 --- a/src/state/models/suggested-actors-view.ts +++ b/src/state/models/suggested-actors-view.ts @@ -2,6 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {AppBskyActorGetSuggestions as GetSuggestions} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import {bundleAsync} from '../../lib/async/bundle' const PAGE_SIZE = 30 @@ -15,7 +16,6 @@ export class SuggestedActorsViewModel { error = '' hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data suggestions: SuggestedActor[] = [] @@ -49,41 +49,7 @@ export class SuggestedActorsViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise - } - this._loadMorePromise = this._load(isRefreshing) - try { - await this._loadMorePromise - } finally { - this._loadMorePromise = undefined - } - } - - // state transitions - // = - - private _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - private _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - this.rootStore.log.error('Failed to fetch suggested actors', err) - } - } - - // loader functions - // = - - private async _load(replace = false) { + loadMore = bundleAsync(async (replace: boolean = false) => { if (!replace && !this.hasMore) { return } @@ -121,5 +87,24 @@ export class SuggestedActorsViewModel { } catch (e: any) { this._xIdle(e) } + }) + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch suggested actors', err) + } } } diff --git a/src/state/models/user-followers-view.ts b/src/state/models/user-followers-view.ts index 46b9c15282..299b251d64 100644 --- a/src/state/models/user-followers-view.ts +++ b/src/state/models/user-followers-view.ts @@ -5,6 +5,7 @@ import { } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import {bundleAsync} from '../../lib/async/bundle' const PAGE_SIZE = 30 @@ -19,7 +20,6 @@ export class UserFollowersViewModel { params: GetFollowers.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data subject: ActorRef.WithInfo = { @@ -63,17 +63,27 @@ export class UserFollowersViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return } - this._loadMorePromise = this._load(isRefreshing) + this._xLoading(replace) try { - await this._loadMorePromise - } finally { - this._loadMorePromise = undefined + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) } - } + }) // state transitions // = @@ -94,31 +104,9 @@ export class UserFollowersViewModel { } } - // loader functions + // helper functions // = - private async _load(replace = false) { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.api.app.bsky.graph.getFollowers(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - private _replaceAll(res: GetFollowers.Response) { this.followers = [] this._appendAll(res) diff --git a/src/state/models/user-follows-view.ts b/src/state/models/user-follows-view.ts index 6562deae82..3f7049ea99 100644 --- a/src/state/models/user-follows-view.ts +++ b/src/state/models/user-follows-view.ts @@ -5,6 +5,7 @@ import { } from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import {bundleAsync} from '../../lib/async/bundle' const PAGE_SIZE = 30 @@ -19,7 +20,6 @@ export class UserFollowsViewModel { params: GetFollows.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data subject: ActorRef.WithInfo = { @@ -63,17 +63,27 @@ export class UserFollowsViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return } - this._loadMorePromise = this._load(isRefreshing) + this._xLoading(replace) try { - await this._loadMorePromise - } finally { - this._loadMorePromise = undefined + const params = Object.assign({}, this.params, { + limit: PAGE_SIZE, + before: replace ? undefined : this.loadMoreCursor, + }) + const res = await this.rootStore.api.app.bsky.graph.getFollows(params) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) } - } + }) // state transitions // = @@ -94,31 +104,9 @@ export class UserFollowsViewModel { } } - // loader functions + // helper functions // = - private async _load(replace = false) { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const params = Object.assign({}, this.params, { - limit: PAGE_SIZE, - before: replace ? undefined : this.loadMoreCursor, - }) - const res = await this.rootStore.api.app.bsky.graph.getFollows(params) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - } - private _replaceAll(res: GetFollows.Response) { this.follows = [] this._appendAll(res) diff --git a/src/state/models/votes-view.ts b/src/state/models/votes-view.ts index e38324504b..926d8718c2 100644 --- a/src/state/models/votes-view.ts +++ b/src/state/models/votes-view.ts @@ -3,6 +3,7 @@ import {AtUri} from '../../third-party/uri' import {AppBskyFeedGetVotes as GetVotes} from '@atproto/api' import {RootStoreModel} from './root-store' import {cleanError} from '../../lib/strings' +import {bundleAsync} from '../../lib/async/bundle' import * as apilib from '../lib/api' const PAGE_SIZE = 30 @@ -19,7 +20,6 @@ export class VotesViewModel { params: GetVotes.QueryParams hasMore = true loadMoreCursor?: string - private _loadMorePromise: Promise | undefined // data uri: string = '' @@ -56,55 +56,7 @@ export class VotesViewModel { return this.loadMore(true) } - async loadMore(isRefreshing = false) { - if (this._loadMorePromise) { - return this._loadMorePromise - } - this._loadMorePromise = this._load(isRefreshing) - try { - await this._loadMorePromise - } finally { - this._loadMorePromise = undefined - } - } - - // state transitions - // = - - private _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - private _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - this.rootStore.log.error('Failed to fetch votes', err) - } - } - - // loader functions - // = - - private async _resolveUri() { - const urip = new AtUri(this.params.uri) - if (!urip.host.startsWith('did:')) { - try { - urip.host = await apilib.resolveName(this.rootStore, urip.host) - } catch (e: any) { - this.error = e.toString() - } - } - runInAction(() => { - this.resolvedUri = urip.toString() - }) - } - - private async _load(replace = false) { + loadMore = bundleAsync(async (replace: boolean = false) => { if (!replace && !this.hasMore) { return } @@ -128,6 +80,42 @@ export class VotesViewModel { } catch (e: any) { this._xIdle(e) } + }) + + // state transitions + // = + + private _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + private _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch votes', err) + } + } + + // helper functions + // = + + private async _resolveUri() { + const urip = new AtUri(this.params.uri) + if (!urip.host.startsWith('did:')) { + try { + urip.host = await apilib.resolveName(this.rootStore, urip.host) + } catch (e: any) { + this.error = e.toString() + } + } + runInAction(() => { + this.resolvedUri = urip.toString() + }) } private _replaceAll(res: GetVotes.Response) { diff --git a/yarn.lock b/yarn.lock index e62b042a44..edbe0fb9ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3712,6 +3712,11 @@ autoprefixer@^10.4.13: picocolors "^1.0.0" postcss-value-parser "^4.2.0" +await-lock@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef" + integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw== + axe-core@^4.4.3: version "4.6.1" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.1.tgz#79cccdee3e3ab61a8f42c458d4123a6768e6fbce"