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 } }