Retain tab data across recovery and patrol revisits

This commit is contained in:
rcourtman
2026-04-17 19:38:43 +01:00
parent deb701e340
commit 3a5f36da19
9 changed files with 120 additions and 5 deletions
@@ -215,8 +215,9 @@ describe('ResourceDetailDrawer change history section', () => {
'const modeLabel = formatSourceType(resource.sourceType);',
);
expect(resourceDetailDrawerOverviewSource).not.toContain('<span class="text-muted">Mode</span>');
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);');
@@ -109,6 +109,7 @@ export function usePatrolIntelligenceState() {
const patrolStatusState = createNonSuspendingQuery<PatrolStatus | null, string>({
source: () => 'patrol-status',
cacheKey: () => 'patrol-status',
fetcher: async () => {
try {
return await getPatrolStatus();
@@ -345,6 +346,7 @@ export function usePatrolIntelligenceState() {
const patrolRunHistory = createNonSuspendingQuery<PatrolRunRecord[], number>({
source: activityRefreshTrigger,
cacheKey: () => 'patrol-run-history',
fetcher: async () => {
try {
return await getPatrolRunHistory(30);
@@ -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<string>;
}) {
const state = createNonSuspendingQuery<string, string>({
source: () => 'stable-key',
cacheKey: (key) => `${props.cacheNamespace}:${key}`,
fetcher: props.fetcher,
initialValue: 'initial',
});
return (
<div data-testid="query-probe">{`${state.value()}|resolved:${String(state.resolvedOnce())}|loading:${String(state.loading())}`}</div>
);
}
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<string>(() => {}));
const firstRender = render(() => (
<QueryProbe cacheNamespace={cacheNamespace} fetcher={firstFetcher} />
));
await waitFor(() => {
expect(screen.getByTestId('query-probe').textContent).toContain('loaded');
expect(screen.getByTestId('query-probe').textContent).toContain('resolved:true');
});
firstRender.unmount();
render(() => <QueryProbe cacheNamespace={cacheNamespace} fetcher={secondFetcher} />);
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');
});
});
@@ -4,6 +4,7 @@ interface CreateNonSuspendingQueryOptions<T, K> {
source: Accessor<K | null>;
fetcher: (key: K) => Promise<T>;
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<T, K>(options: CreateNonSuspendingQueryOptions<T, K>) {
const [value, setValue] = createSignal<T>(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<T>(initialCached?.value ?? options.initialValue);
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<unknown>(null);
const [resolvedOnce, setResolvedOnce] = createSignal(false);
const [error, setError] = createSignal<unknown>(initialCached?.error ?? null);
const [resolvedOnce, setResolvedOnce] = createSignal(initialCached?.resolvedOnce ?? false);
let latestRequestId = 0;
@@ -34,6 +71,7 @@ export function createNonSuspendingQuery<T, K>(options: CreateNonSuspendingQuery
const run = async (key: K, runOptions: QueryRunOptions = {}): Promise<T> => {
const requestId = ++latestRequestId;
const retainedCacheKey = getRetainedCacheKey(key);
if (!runOptions.background) {
setLoading(true);
}
@@ -54,6 +92,13 @@ export function createNonSuspendingQuery<T, K>(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<T, K>(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<T> => {
@@ -134,6 +134,7 @@ export function useRecoveryPoints(query?: Accessor<RecoveryPointsQuery | null |
const state = createNonSuspendingQuery<RecoveryPointsResponse, string>({
source,
cacheKey: (key) => `recovery-points:${key}`,
fetcher: async (key) => fetchRecoveryPointsResponse(parseSerializedQuery(key)),
initialValue: {
data: [],
@@ -126,6 +126,7 @@ export function useRecoveryPointsFacets(query?: Accessor<RecoveryFacetsQuery | n
const state = createNonSuspendingQuery<RecoveryPointsFacetsResponse, string>({
source,
cacheKey: (key) => `recovery-facets:${key}`,
fetcher: async (key) => fetchFacets(parseSerializedQuery(key)),
initialValue: { data: {} },
pollMs: REFRESH_MS,
@@ -112,6 +112,7 @@ export function useRecoveryPointsSeries(query?: Accessor<RecoverySeriesQuery | n
const state = createNonSuspendingQuery<RecoveryPointsSeriesResponse, string>({
source,
cacheKey: (key) => `recovery-series:${key}`,
fetcher: async (key) => fetchSeries(parseSerializedQuery(key)),
initialValue: { data: [] },
pollMs: REFRESH_MS,
@@ -115,6 +115,7 @@ export function useRecoveryRollups(query?: () => RecoveryRollupsQuery | null | u
const state = createNonSuspendingQuery<ProtectionRollup[], string>({
source,
cacheKey: (key) => `recovery-rollups:${key}`,
fetcher: async (key) => fetchRecoveryRollups(parseSerializedQuery(key)),
initialValue: [],
pollMs: REFRESH_MS,
@@ -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();