refactor(shared/api): lazify queryClient to remove eager-construction footgun

queryClient.ts constructed the TanStack client at module eval but was not
in the sideEffects allowlist, so Rollup treated the module as pure — safe
only while a value-importer keeps it alive (D-3).

- replace the eager `queryClient` singleton with a memoized getQueryClient()
  factory; construction is deferred to first call
- the module is now genuinely side-effect-free, so no sideEffects exception
  is needed and construction can never be legally tree-shaken away
- route all consumers through getQueryClient() (QueryProvider as first
  caller; stores via class fields/observers; tests via a local alias; the
  fontCatalogStore spec mock now overrides getQueryClient)
This commit is contained in:
Ilia Mashkov
2026-06-02 23:13:03 +03:00
parent b390efdabe
commit 60e115309c
13 changed files with 71 additions and 48 deletions
+4 -1
View File
@@ -6,7 +6,7 @@
descendants of this provider. descendants of this provider.
--> -->
<script lang="ts"> <script lang="ts">
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { QueryClientProvider } from '@tanstack/svelte-query'; import { QueryClientProvider } from '@tanstack/svelte-query';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -18,6 +18,9 @@ interface Props {
} }
let { children }: Props = $props(); let { children }: Props = $props();
// First call to the lazy singleton — constructs the shared client for the app.
const queryClient = getQueryClient();
</script> </script>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -19,7 +19,9 @@ vi.mock('$shared/api/api', () => ({
})); }));
import { api } from '$shared/api/api'; import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { FontResponseError } from '../../lib/errors/errors'; import { FontResponseError } from '../../lib/errors/errors';
import { import {
+2 -2
View File
@@ -11,7 +11,7 @@
*/ */
import { api } from '$shared/api/api'; import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils'; import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils'; import type { QueryParams } from '$shared/lib/utils';
@@ -26,7 +26,7 @@ import type { UnifiedFont } from '../../model/types';
*/ */
export function seedFontCache(fonts: UnifiedFont[]): void { export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => { fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font); getQueryClient().setQueryData(fontKeys.detail(font.id), font);
}); });
} }
@@ -27,18 +27,21 @@ vi.mock('$shared/api/queryClient', async importOriginal => {
*/ */
const { QueryClient } = await import('@tanstack/query-core'); const { QueryClient } = await import('@tanstack/query-core');
const actual = await importOriginal<typeof import('$shared/api/queryClient')>(); const actual = await importOriginal<typeof import('$shared/api/queryClient')>();
const mockClient = new QueryClient({
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
});
return { return {
...actual, ...actual,
queryClient: new QueryClient({ getQueryClient: () => mockClient,
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
}),
}; };
}); });
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() })); vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { fetchProxyFonts } from '../../../api'; import { fetchProxyFonts } from '../../../api';
const queryClient = getQueryClient();
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>; const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number }; type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
@@ -1,7 +1,7 @@
import { import {
DEFAULT_QUERY_GC_TIME_MS, DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS, DEFAULT_QUERY_STALE_TIME_MS,
queryClient, getQueryClient,
} from '$shared/api/queryClient'; } from '$shared/api/queryClient';
import { import {
type InfiniteData, type InfiniteData,
@@ -46,7 +46,7 @@ export class FontCatalogStore {
readonly unknown[], readonly unknown[],
PageParam PageParam
>; >;
#qc = queryClient; #qc = getQueryClient();
#unsubscribe: () => void; #unsubscribe: () => void;
constructor(params: FontStoreParams = {}) { constructor(params: FontStoreParams = {}) {
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { fontKeys } from '$shared/api/queryKeys'; import { fontKeys } from '$shared/api/queryKeys';
import { import {
beforeEach, beforeEach,
@@ -20,7 +20,7 @@ import type { FilterMetadata } from '$features/FilterAndSortFonts/api/filters/fi
import { import {
DEFAULT_QUERY_GC_TIME_MS, DEFAULT_QUERY_GC_TIME_MS,
DEFAULT_QUERY_STALE_TIME_MS, DEFAULT_QUERY_STALE_TIME_MS,
queryClient, getQueryClient,
} from '$shared/api/queryClient'; } from '$shared/api/queryClient';
import { import {
type QueryKey, type QueryKey,
@@ -49,7 +49,7 @@ export class AvailableFilterStore {
/** /**
* Shared query client * Shared query client
*/ */
protected qc = queryClient; protected qc = getQueryClient();
/** /**
* Creates a new filters store * Creates a new filters store
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { import {
afterEach, afterEach,
beforeEach, beforeEach,
+35 -28
View File
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
*/ */
export const QUERY_RETRY_MAX_DELAY_MS = 30000; export const QUERY_RETRY_MAX_DELAY_MS = 30000;
let queryClientInstance: QueryClient | undefined;
/** /**
* TanStack Query client instance * Shared TanStack Query client (lazy singleton).
* *
* Configured for optimal caching and refetching behavior. * Construction is deferred to the first call so importing this module is inert:
* Used by all font stores for data fetching and caching. * module eval runs no `new QueryClient()`, so the module is genuinely
* side-effect-free and needs no `sideEffects` allowlist exception. The
* app-layer `QueryProvider` is the first caller; every store reuses the same
* instance. Matches the lazy-accessor pattern used by the font stores.
* *
* Cache behavior: * Cache behavior:
* - Data stays fresh for 5 minutes (staleTime) * - Data stays fresh for 5 minutes (staleTime)
@@ -39,30 +44,32 @@ export const QUERY_RETRY_MAX_DELAY_MS = 30000;
* - No refetch on window focus (reduces unnecessary network requests) * - No refetch on window focus (reduces unnecessary network requests)
* - 3 retries with exponential backoff on failure * - 3 retries with exponential backoff on failure
*/ */
export const queryClient = new QueryClient({ export function getQueryClient(): QueryClient {
defaultOptions: { return (queryClientInstance ??= new QueryClient({
queries: { defaultOptions: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS, queries: {
gcTime: DEFAULT_QUERY_GC_TIME_MS, staleTime: DEFAULT_QUERY_STALE_TIME_MS,
/** gcTime: DEFAULT_QUERY_GC_TIME_MS,
* Don't refetch when window regains focus /**
*/ * Don't refetch when window regains focus
refetchOnWindowFocus: false, */
/** refetchOnWindowFocus: false,
* Refetch on mount if data is stale /**
*/ * Refetch on mount if data is stale
refetchOnMount: true, */
retry: (failureCount, error) => { refetchOnMount: true,
if (error instanceof NonRetryableError) { retry: (failureCount, error) => {
return false; if (error instanceof NonRetryableError) {
} return false;
return failureCount < QUERY_RETRY_COUNT; }
return failureCount < QUERY_RETRY_COUNT;
},
/**
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex =>
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
}, },
/**
* Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s
*/
retryDelay: attemptIndex =>
Math.min(QUERY_RETRY_BASE_DELAY_MS * 2 ** attemptIndex, QUERY_RETRY_MAX_DELAY_MS),
}, },
}, }));
}); }
@@ -1,4 +1,4 @@
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import { import {
QueryObserver, QueryObserver,
type QueryObserverOptions, type QueryObserverOptions,
@@ -20,7 +20,7 @@ export abstract class BaseQueryStore<TData, TError = Error> {
#unsubscribe: () => void; #unsubscribe: () => void;
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) { constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
this.#observer = new QueryObserver(queryClient, options); this.#observer = new QueryObserver(getQueryClient(), options);
this.#unsubscribe = this.#observer.subscribe(result => { this.#unsubscribe = this.#observer.subscribe(result => {
this.#result = result; this.#result = result;
}); });
@@ -1,4 +1,6 @@
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { import {
beforeEach, beforeEach,
describe, describe,
@@ -11,7 +11,9 @@
import type { UnifiedFont } from '$entities/Font'; import type { UnifiedFont } from '$entities/Font';
import { UNIFIED_FONTS } from '$entities/Font/testing'; import { UNIFIED_FONTS } from '$entities/Font/testing';
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import { import {
beforeEach, beforeEach,
describe, describe,
+2 -2
View File
@@ -1,4 +1,4 @@
import { queryClient } from '$shared/api/queryClient'; import { getQueryClient } from '$shared/api/queryClient';
import * as matchers from '@testing-library/jest-dom/matchers'; import * as matchers from '@testing-library/jest-dom/matchers';
import { cleanup } from '@testing-library/svelte'; import { cleanup } from '@testing-library/svelte';
import { import {
@@ -14,7 +14,7 @@ expect.extend(matchers);
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
queryClient.clear(); getQueryClient().clear();
}); });
// Mock window.matchMedia for components that use it // Mock window.matchMedia for components that use it