mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Retain tab data across recovery and patrol revisits
This commit is contained in:
+2
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user