refactor(landing): startup sync as a query

"Once per organization and session" is what a query keyed by organization
already does, so the startup sync no longer needs to emulate it on top of a
mutation (looking the run up in the mutation cache from an effect, keeping
runs with `gcTime: Infinity`, reading the status through `useMutationState`
to survive StrictMode). It is now `startupSyncQuery(orgId)`: every reader
shares one run, `refetch` is the retry, and `retryOnMount: false` keeps a
failed run from retrying by itself when the layout mounts again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Curry YangandClaude Opus 5.5 authored and GitHub committed 2026-11-03 16:25:21 +08:00
1 parent d16ead978c
commit df74b47fa6
2 files changed
+38 -41

No files matched your search

@@ -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<typeof useMutation>[0]) => useMutation(options, queryClient),
useServerQuery: (options: Parameters<typeof useQuery>[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();
@@ -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;
}