diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx
index dc356259f3e..7b773374e3a 100644
--- a/mobile/app/h/[hostId]/tasks.tsx
+++ b/mobile/app/h/[hostId]/tasks.tsx
@@ -3282,7 +3282,11 @@ export default function MobileTasksScreen() {
setItems([])
return
}
- const currentRepos = await repoListEnsureLoaded()
+ // Why: Linear issues do not need the repo list, only the composer does, so
+ // start the fetch either way but never make Linear wait on it.
+ const repoListRequest = repoListEnsureLoaded()
+ void repoListRequest.catch(() => {})
+ const currentRepos = provider === 'linear' ? [] : await repoListRequest
if (!isCurrent()) {
return
}
@@ -8713,7 +8717,7 @@ export default function MobileTasksScreen() {
refreshGitHubProject()
return
}
- void loadTasks({ silent: true })
+ refreshTasks()
}}
>
diff --git a/mobile/src/tasks/github-project-repo-list-load.test.ts b/mobile/src/tasks/github-project-repo-list-load.test.ts
index f4d7fac3412..e8c8bc7a458 100644
--- a/mobile/src/tasks/github-project-repo-list-load.test.ts
+++ b/mobile/src/tasks/github-project-repo-list-load.test.ts
@@ -21,12 +21,14 @@ describe('mobile GitHub Project repo list loading', () => {
// loaded, so hostedRepos stayed empty and every board row was filtered out.
it('loads the repo list before project mode bails out', () => {
const body = loadTasksBody()
- const repoLoad = body.indexOf('await repoListEnsureLoaded()')
+ const repoLoad = body.indexOf('const repoListRequest = repoListEnsureLoaded()')
+ const repoAwait = body.indexOf('await repoListRequest')
const projectReturn = body.indexOf("provider === 'github' && githubMode === 'project'")
expect(repoLoad, 'loadTasks must load the repo list').toBeGreaterThan(-1)
expect(projectReturn, 'project mode must still short-circuit loadTasks').toBeGreaterThan(-1)
+ expect(repoAwait, 'the repo list must be awaited, not just started').toBeGreaterThan(repoLoad)
expect(projectReturn, 'project mode must bail out after the repo list load').toBeGreaterThan(
- repoLoad
+ repoAwait
)
})
diff --git a/mobile/src/tasks/use-host-repo-list.test.tsx b/mobile/src/tasks/use-host-repo-list.test.tsx
index cbc2a186038..5b35fb0a34c 100644
--- a/mobile/src/tasks/use-host-repo-list.test.tsx
+++ b/mobile/src/tasks/use-host-repo-list.test.tsx
@@ -161,6 +161,47 @@ describe('useHostRepoList', () => {
expect(probe.resource.state.repos).toEqual([{ id: 'from-host-b' }])
})
+ // Regression: A -> B -> A reuses the same client object, so a client-key match
+ // alone let a stale request for A overwrite a newer result for A.
+ it('discards a stale response after returning to the original client', async () => {
+ const probe = mountResource()
+ await act(async () => {
+ void probe.resource.ensureLoaded()
+ })
+ probe.rebind('client-b')
+ probe.rebind('client-a')
+ await act(async () => {
+ void probe.resource.ensureLoaded()
+ })
+ await probe.settle(1, [{ id: 'new-a' }])
+ expect(probe.resource.state.repos).toEqual([{ id: 'new-a' }])
+
+ await probe.settle(0, [{ id: 'stale-a' }])
+ expect(probe.resource.state.repos).toEqual([{ id: 'new-a' }])
+ })
+
+ // Regression: a refresh calls reload() and loadTasks() in the same event, and
+ // React has not rendered the `requested` dispatch yet, so ensureLoaded saw
+ // `loaded` and returned the very list the reload was replacing.
+ it('joins an in-flight reload instead of serving the list it will replace', async () => {
+ const probe = mountResource()
+ await act(async () => {
+ void probe.resource.ensureLoaded()
+ })
+ await probe.settle(0, [{ id: 'stale' }])
+
+ let joined: Repo[] = []
+ await act(async () => {
+ void probe.resource.reload()
+ void probe.resource.ensureLoaded().then((repos) => {
+ joined = repos
+ })
+ })
+ expect(probe.callCount).toBe(2)
+ await probe.settle(1, [{ id: 'fresh' }])
+ expect(joined).toEqual([{ id: 'fresh' }])
+ })
+
it('stays idle with no connection instead of caching an empty answer', async () => {
const probe = mountResource()
probe.rebind('client-a', false)
diff --git a/mobile/src/tasks/use-host-repo-list.ts b/mobile/src/tasks/use-host-repo-list.ts
index 334048c7124..7ecbef9d69f 100644
--- a/mobile/src/tasks/use-host-repo-list.ts
+++ b/mobile/src/tasks/use-host-repo-list.ts
@@ -68,14 +68,16 @@ export function useHostRepoList(
dispatch({ type: 'requested' })
try {
const repos = await fetchNow()
- if (boundKeyRef.current !== requestKey) {
+ // Why: A -> B -> A reuses the same client, so matching the key alone
+ // would let a stale request for A overwrite a newer result for A.
+ if (boundKeyRef.current !== requestKey || requestIdRef.current !== requestId) {
return []
}
reposRef.current = repos
dispatch({ type: 'resolved', repos })
return repos
} catch (err) {
- if (boundKeyRef.current === requestKey) {
+ if (boundKeyRef.current === requestKey && requestIdRef.current === requestId) {
dispatch({
type: 'failed',
error: err instanceof Error ? err.message : 'Unknown error'
@@ -92,8 +94,12 @@ export function useHostRepoList(
return request
}
callbacksRef.current = {
+ // Why: within one event React has not rendered the `requested` dispatch
+ // yet, so the status still reads `loaded`. Join the in-flight request
+ // instead of handing back the list it is about to replace.
ensureLoaded: () =>
- needsHostRepoListFetch(stateRef.current) ? reload() : Promise.resolve(reposRef.current),
+ inFlightRef.current ??
+ (needsHostRepoListFetch(stateRef.current) ? reload() : Promise.resolve(reposRef.current)),
reload
}
}