({
+ 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();