diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 0f14ab62f..1a7722746 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -215,8 +215,9 @@ describe('ResourceDetailDrawer change history section', () => { 'const modeLabel = formatSourceType(resource.sourceType);', ); expect(resourceDetailDrawerOverviewSource).not.toContain('Mode'); + expect(createNonSuspendingQuerySource).toContain('const retainedQueryCache = new Map<'); expect(createNonSuspendingQuerySource).toContain( - 'const [resolvedOnce, setResolvedOnce] = createSignal(false);', + 'export function resetCreateNonSuspendingQueryCacheForTest()', ); expect(createNonSuspendingQuerySource).toContain('setResolvedOnce(true);'); expect(createNonSuspendingQuerySource).toContain('setResolvedOnce(false);'); diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 685a65f9e..c0877f436 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -109,6 +109,7 @@ export function usePatrolIntelligenceState() { const patrolStatusState = createNonSuspendingQuery({ source: () => 'patrol-status', + cacheKey: () => 'patrol-status', fetcher: async () => { try { return await getPatrolStatus(); @@ -345,6 +346,7 @@ export function usePatrolIntelligenceState() { const patrolRunHistory = createNonSuspendingQuery({ source: activityRefreshTrigger, + cacheKey: () => 'patrol-run-history', fetcher: async () => { try { return await getPatrolRunHistory(30); diff --git a/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx new file mode 100644 index 000000000..d33e04a05 --- /dev/null +++ b/frontend-modern/src/hooks/__tests__/createNonSuspendingQuery.test.tsx @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { + createNonSuspendingQuery, + resetCreateNonSuspendingQueryCacheForTest, +} from '@/hooks/createNonSuspendingQuery'; + +afterEach(() => { + resetCreateNonSuspendingQueryCacheForTest(); + cleanup(); +}); + +function QueryProbe(props: { + cacheNamespace: string; + fetcher: (key: string) => Promise; +}) { + const state = createNonSuspendingQuery({ + source: () => 'stable-key', + cacheKey: (key) => `${props.cacheNamespace}:${key}`, + fetcher: props.fetcher, + initialValue: 'initial', + }); + + return ( +
{`${state.value()}|resolved:${String(state.resolvedOnce())}|loading:${String(state.loading())}`}
+ ); +} + +describe('createNonSuspendingQuery', () => { + it('reuses the last fulfilled value when the same query remounts', async () => { + const cacheNamespace = `query-cache-${Date.now()}`; + const firstFetcher = vi.fn(async () => 'loaded'); + const secondFetcher = vi.fn(() => new Promise(() => {})); + + const firstRender = render(() => ( + + )); + + await waitFor(() => { + expect(screen.getByTestId('query-probe').textContent).toContain('loaded'); + expect(screen.getByTestId('query-probe').textContent).toContain('resolved:true'); + }); + + firstRender.unmount(); + + render(() => ); + + await waitFor(() => { + expect(secondFetcher).toHaveBeenCalledWith('stable-key'); + }); + + expect(screen.getByTestId('query-probe').textContent).toContain('loaded'); + expect(screen.getByTestId('query-probe').textContent).toContain('resolved:true'); + expect(screen.getByTestId('query-probe').textContent).toContain('loading:false'); + expect(screen.getByTestId('query-probe').textContent).not.toContain('initial'); + }); +}); diff --git a/frontend-modern/src/hooks/createNonSuspendingQuery.ts b/frontend-modern/src/hooks/createNonSuspendingQuery.ts index 80f5f4cdf..c34f8791b 100644 --- a/frontend-modern/src/hooks/createNonSuspendingQuery.ts +++ b/frontend-modern/src/hooks/createNonSuspendingQuery.ts @@ -4,6 +4,7 @@ interface CreateNonSuspendingQueryOptions { source: Accessor; fetcher: (key: K) => Promise; initialValue: T; + cacheKey?: (key: K) => string | null; pollMs?: number; } @@ -15,11 +16,47 @@ interface QueryRunOptions { * Keep query-backed surfaces out of the app-level Suspense boundary by * retaining the last fulfilled value while the next request is in flight. */ +const retainedQueryCache = new Map< + string, + { error: unknown; resolvedOnce: boolean; value: unknown } +>(); + +export function resetCreateNonSuspendingQueryCacheForTest() { + retainedQueryCache.clear(); +} + export function createNonSuspendingQuery(options: CreateNonSuspendingQueryOptions) { - const [value, setValue] = createSignal(options.initialValue); + const getRetainedCacheKey = (key: K | null): string | null => { + if (key === null || !options.cacheKey) { + return null; + } + return options.cacheKey(key); + }; + + const readRetainedValue = (key: K | null) => { + const cacheKey = getRetainedCacheKey(key); + if (!cacheKey) { + return null; + } + const cached = retainedQueryCache.get(cacheKey); + if (!cached) { + return null; + } + return cached as { error: unknown; resolvedOnce: boolean; value: T }; + }; + + const applyRetainedValue = (cached: { error: unknown; resolvedOnce: boolean; value: T }) => { + setValue(() => cached.value); + setError(cached.error); + setResolvedOnce(cached.resolvedOnce); + }; + + const initialCached = readRetainedValue(options.source()); + + const [value, setValue] = createSignal(initialCached?.value ?? options.initialValue); const [loading, setLoading] = createSignal(false); - const [error, setError] = createSignal(null); - const [resolvedOnce, setResolvedOnce] = createSignal(false); + const [error, setError] = createSignal(initialCached?.error ?? null); + const [resolvedOnce, setResolvedOnce] = createSignal(initialCached?.resolvedOnce ?? false); let latestRequestId = 0; @@ -34,6 +71,7 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery const run = async (key: K, runOptions: QueryRunOptions = {}): Promise => { const requestId = ++latestRequestId; + const retainedCacheKey = getRetainedCacheKey(key); if (!runOptions.background) { setLoading(true); } @@ -54,6 +92,13 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery } finally { if (requestId === latestRequestId) { setResolvedOnce(true); + if (retainedCacheKey) { + retainedQueryCache.set(retainedCacheKey, { + error: error(), + resolvedOnce: true, + value: value(), + }); + } if (!runOptions.background) { setLoading(false); } @@ -67,7 +112,11 @@ export function createNonSuspendingQuery(options: CreateNonSuspendingQuery reset(); return; } - void run(key); + const cached = readRetainedValue(key); + if (cached) { + applyRetainedValue(cached); + } + void run(key, { background: Boolean(cached) }); }); const refetch = async (runOptions: QueryRunOptions = {}): Promise => { diff --git a/frontend-modern/src/hooks/useRecoveryPoints.ts b/frontend-modern/src/hooks/useRecoveryPoints.ts index bfabb9195..9afe7b339 100644 --- a/frontend-modern/src/hooks/useRecoveryPoints.ts +++ b/frontend-modern/src/hooks/useRecoveryPoints.ts @@ -134,6 +134,7 @@ export function useRecoveryPoints(query?: Accessor({ source, + cacheKey: (key) => `recovery-points:${key}`, fetcher: async (key) => fetchRecoveryPointsResponse(parseSerializedQuery(key)), initialValue: { data: [], diff --git a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts b/frontend-modern/src/hooks/useRecoveryPointsFacets.ts index eea2d98a4..101d16f3c 100644 --- a/frontend-modern/src/hooks/useRecoveryPointsFacets.ts +++ b/frontend-modern/src/hooks/useRecoveryPointsFacets.ts @@ -126,6 +126,7 @@ export function useRecoveryPointsFacets(query?: Accessor({ source, + cacheKey: (key) => `recovery-facets:${key}`, fetcher: async (key) => fetchFacets(parseSerializedQuery(key)), initialValue: { data: {} }, pollMs: REFRESH_MS, diff --git a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts b/frontend-modern/src/hooks/useRecoveryPointsSeries.ts index 19beac4d8..2b4d10e18 100644 --- a/frontend-modern/src/hooks/useRecoveryPointsSeries.ts +++ b/frontend-modern/src/hooks/useRecoveryPointsSeries.ts @@ -112,6 +112,7 @@ export function useRecoveryPointsSeries(query?: Accessor({ source, + cacheKey: (key) => `recovery-series:${key}`, fetcher: async (key) => fetchSeries(parseSerializedQuery(key)), initialValue: { data: [] }, pollMs: REFRESH_MS, diff --git a/frontend-modern/src/hooks/useRecoveryRollups.ts b/frontend-modern/src/hooks/useRecoveryRollups.ts index 3f3b668ad..9165fd279 100644 --- a/frontend-modern/src/hooks/useRecoveryRollups.ts +++ b/frontend-modern/src/hooks/useRecoveryRollups.ts @@ -115,6 +115,7 @@ export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | u const state = createNonSuspendingQuery({ source, + cacheKey: (key) => `recovery-rollups:${key}`, fetcher: async (key) => fetchRecoveryRollups(parseSerializedQuery(key)), initialValue: [], pollMs: REFRESH_MS, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index f917987da..ad6292469 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library'; import { Suspense, createSignal } from 'solid-js'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; import { resetAIRuntimeState } from '@/stores/aiRuntimeState'; import { getPublicPricingUrl } from '@/utils/pricingHandoff'; @@ -323,6 +324,7 @@ const defaultAISettings = { describe('AIIntelligence entitlement gating', () => { beforeEach(() => { + resetCreateNonSuspendingQueryCacheForTest(); resetAIRuntimeState(); getPatrolStatusMock.mockReset(); getPatrolAutonomySettingsMock.mockReset();