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
+35 -28
View File
@@ -27,11 +27,16 @@ export const QUERY_RETRY_BASE_DELAY_MS = 1000;
*/
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.
* Used by all font stores for data fetching and caching.
* Construction is deferred to the first call so importing this module is inert:
* 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:
* - 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)
* - 3 retries with exponential backoff on failure
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
retry: (failureCount, error) => {
if (error instanceof NonRetryableError) {
return false;
}
return failureCount < QUERY_RETRY_COUNT;
export function getQueryClient(): QueryClient {
return (queryClientInstance ??= new QueryClient({
defaultOptions: {
queries: {
staleTime: DEFAULT_QUERY_STALE_TIME_MS,
gcTime: DEFAULT_QUERY_GC_TIME_MS,
/**
* Don't refetch when window regains focus
*/
refetchOnWindowFocus: false,
/**
* Refetch on mount if data is stale
*/
refetchOnMount: true,
retry: (failureCount, error) => {
if (error instanceof NonRetryableError) {
return false;
}
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),
},
},
});
}));
}