diff --git a/packages/insomnia/src/ui/hooks/use-startup-sync.test.tsx b/packages/insomnia/src/ui/hooks/use-startup-sync.test.tsx index 0efa446cf5..b6a4712b7e 100644 --- a/packages/insomnia/src/ui/hooks/use-startup-sync.test.tsx +++ b/packages/insomnia/src/ui/hooks/use-startup-sync.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { QueryClient, useMutation } from '@tanstack/react-query'; +import { QueryClient, useMutation, useQuery } from '@tanstack/react-query'; import React, { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -18,9 +18,9 @@ vi.mock('~/ui/organization-utils', () => ({ findMigrationTargetSpaceId: vi.fn(() => 'org_target'), migrateProjectsUnderOrganization: vi.fn(), })); -vi.mock('~/ui/context/app/server-data-context', () => ({ useServerDataQueryClient: () => queryClient })); vi.mock('~/ui/hooks/use-query', () => ({ useServerMutation: (options: Parameters[0]) => useMutation(options, queryClient), + useServerQuery: (options: Parameters[0]) => useQuery(options, queryClient), })); import { services } from 'insomnia-data'; @@ -50,7 +50,7 @@ const render = async (organizationId: string) => { await flush(); }; -// TanStack Query delivers mutation results through its batched (setTimeout) notifier. +// TanStack Query delivers query results through its batched (setTimeout) notifier. const flush = () => act(() => new Promise(resolve => setTimeout(resolve, 0))); describe('useStartupSync', () => { @@ -121,14 +121,18 @@ describe('useStartupSync', () => { expect(result.current?.status).toBe('idle'); }); - it('reports an error without retrying by itself, and a retry runs the full task set again', async () => { + it('reports an error without retrying by itself, even on remount, and a retry runs the full task set again', async () => { vi.mocked(syncProjects).mockRejectedValueOnce(new Error('network')); await render('org_1'); expect(result.current?.status).toBe('error'); + await render('org_1'); + act(() => root.unmount()); + root = createRoot(container); await render('org_1'); expect(syncProjects).toHaveBeenCalledTimes(1); + expect(result.current?.status).toBe('error'); await act(async () => result.current?.retry()); await flush(); diff --git a/packages/insomnia/src/ui/hooks/use-startup-sync.ts b/packages/insomnia/src/ui/hooks/use-startup-sync.ts index 0c6e01dbac..3b562ca382 100644 --- a/packages/insomnia/src/ui/hooks/use-startup-sync.ts +++ b/packages/insomnia/src/ui/hooks/use-startup-sync.ts @@ -1,12 +1,11 @@ -import { useMutationState } from '@tanstack/react-query'; +import { queryOptions } from '@tanstack/react-query'; import type { Organization } from 'insomnia-api'; import { services } from 'insomnia-data'; -import { useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { syncProjects } from '~/common/project'; import { invariant } from '~/common/utils/invariant'; -import { useServerDataQueryClient } from '~/ui/context/app/server-data-context'; -import { useServerMutation } from '~/ui/hooks/use-query'; +import { useServerMutation, useServerQuery } from '~/ui/hooks/use-query'; import { findMigrationTargetSpaceId, migrateProjectsUnderOrganization } from '~/ui/organization-utils'; // Migrates legacy projects into the account's organizations and syncs the team projects of the @@ -29,7 +28,30 @@ async function runStartupSync(organizationId: string) { await syncProjects(organizationId); } -const startupSyncMutationKey = (organizationId: string) => ['startup-sync', organizationId]; +/** + * The startup sync of one organization as a query: one run per organization and session, shared by + * every component that reads it. Its data is only a "done" marker, so it never goes stale and is + * kept for the session; signing out clears the cache, so the next session syncs again. A failed run + * is not retried by itself, not even when a component mounts again: the sync indicator offers the + * retry. + */ +export const startupSyncQuery = (organizationId: string) => + queryOptions({ + queryKey: ['startup-sync', organizationId], + queryFn: async () => { + try { + await runStartupSync(organizationId); + } catch (error) { + console.log('Failed to run async task', error); + throw error; + } + return null; + }, + staleTime: Infinity, + gcTime: Infinity, + retry: false, + retryOnMount: false, + }); /** * Runs the startup sync in the background the first time an organization is entered in this @@ -37,40 +59,11 @@ const startupSyncMutationKey = (organizationId: string) => ['startup-sync', orga * organizations). A retry always runs the full task set again. */ export function useStartupSync(organizationId: string) { - const queryClient = useServerDataQueryClient(); - const mutationKey = startupSyncMutationKey(organizationId); - const { mutate } = useServerMutation({ - mutationKey, - mutationFn: runStartupSync, - onError: error => console.log('Failed to run async task', error), - // Keep every run for the session: it marks the organization as synced, and a failed one keeps - // the indicator offering a retry. Signing out clears the cache, so the next session syncs again. - gcTime: Infinity, - }); - // Read the status from the mutation cache rather than from `useMutation`: under StrictMode the - // simulated unmount detaches its observer from the in-flight run, and as the effect below skips - // an organization that already has a run, nothing would re-attach it. - const statuses = useMutationState( - { filters: { mutationKey, exact: true }, select: mutation => mutation.state.status }, - queryClient, - ); - const status = statuses.at(-1) ?? 'idle'; - - useEffect(() => { - // Once per organization: a run in the cache, in flight or settled, means it was synced already. - // This also absorbs the StrictMode double effect. - const hasRun = queryClient - .getMutationCache() - .find({ mutationKey: startupSyncMutationKey(organizationId), exact: true }); - if (!hasRun) { - mutate(organizationId); - } - }, [mutate, organizationId, queryClient]); - - const retry = useCallback(() => mutate(organizationId), [mutate, organizationId]); + const { isFetching, isError, refetch } = useServerQuery(startupSyncQuery(organizationId)); + const retry = useCallback(() => refetch(), [refetch]); return { - status: status === 'success' ? 'idle' : status, + status: isFetching ? 'pending' : isError ? 'error' : 'idle', retry, } as const; }