From c8a52377a04a01500d753d59e86408ec7b68ca2c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 14 May 2026 10:28:16 +0100 Subject: [PATCH] Stabilize infrastructure resource hydration --- .../v6/internal/subsystems/agent-lifecycle.md | 5 ++ .../v6/internal/subsystems/api-contracts.md | 7 ++ .../subsystems/frontend-primitives.md | 9 +++ .../subsystems/performance-and-scalability.md | 7 ++ .../internal/subsystems/storage-recovery.md | 5 ++ .../internal/subsystems/unified-resources.md | 9 ++- .../__tests__/settingsArchitecture.test.ts | 4 ++ .../__tests__/useConnectionsLedger.test.ts | 67 +++++++++++++++++- .../Settings/useConnectionsLedger.ts | 38 +++++----- ...frastructurePageSurface.guardrails.test.ts | 1 + .../useInfrastructurePageState.ts | 4 +- .../__tests__/useUnifiedResources.test.ts | 61 ++++++++++++++++ .../src/hooks/useUnifiedResources.ts | 70 ++++++++++++++++--- 13 files changed, 252 insertions(+), 35 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 40c556017..abbb8bcb3 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -556,6 +556,11 @@ profile and assignment columns, but embedded table framing must route through come from the explicit nested `fleet` objects on `/api/connections`; Settings surfaces may rank or compact those signals, but they must not reconstruct them from table copy, status badge labels, or provider-local error strings. + The connections ledger is a retained-value settings query: polling or manual + reloads must preserve the last fulfilled connected-systems rows while the + next `/api/connections` request is in flight, and must not route the + Infrastructure settings table through app-level Suspense or a blank loading + replacement. The lifecycle-owned command-policy projection must preserve desired server policy and applied agent report truth as separate facts. Desired disabled with applied enabled, and desired enabled with applied disabled, are both diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 6e176f599..83e28889a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -350,6 +350,13 @@ the canonical monitored-system blocked payload. 1. Add or change payload fields through handler + contract tests together 2. Update frontend API types in lockstep with backend contract changes. Websocket-backed API consumers such as `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` may read runtime context only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx`, because payload ownership remains in the API contract rather than the root shell. +2a. Route settings infrastructure connected-system ledgers through + `/api/connections` and `frontend-modern/src/components/Settings/useConnectionsLedger.ts` + together. The frontend ledger may retain the last fulfilled connection + snapshot while polling or manual reload is in flight, but that retention is + only a fetch lifecycle rule; it must not synthesize rows, downgrade backend + fleet state, or replace the shared connection projection with page-local + placeholders. 3. Add dedicated contract tests for new stable payloads 3a. Route diagnostics payload fields and user-facing diagnostics copy through `internal/api/diagnostics.go`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5546e538c..ab602f9d0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -639,6 +639,10 @@ prompt explain the same operator-facing priority. backend-authored cluster members, the table primitive must render those nodes as child composition beneath the cluster row rather than flattening them back into peer top-level systems or hiding them entirely. + The same table shell must keep fulfilled rows visible across polling and + manual reloads by using a retained-value query boundary, not app-level + Suspense or a blank loading replacement, so configured infrastructure does + not disappear while the next `/api/connections` request is in flight. The systems table and setup summary must count the same visible posture highlights they render, not hidden raw fleet signals. Passive attached-agent config or rollout handshakes whose only cause is a missing comparable @@ -1732,6 +1736,11 @@ owns infrastructure route/deep-link synchronization. Future feature surfaces under `frontend-modern/src/features/` should follow that same pattern instead of letting page files accumulate route sync, filter, and modal orchestration inline. +The infrastructure feature state owner may opt into websocket-first unified +resource hydration only when it also schedules canonical REST revalidation +after the first-paint settle window; shared route composition must not re-route +the table through a blocking resource fetch just to confirm infrastructure that +the realtime store has already reported. Infrastructure summary and detail surfaces now also use the shared normalized identity lookup helper from `frontend-modern/src/utils/resourceIdentity.ts` so dotted hostnames and alias variants stay consistent between the shared diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index f5ce88a30..96d99cc8b 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -683,6 +683,13 @@ REST fetch. Route-owned trend loading must also key off stable target identity and selected range only; reconciled resource snapshots that do not change the effective target set must not trigger duplicate infrastructure or storage chart requests. +Infrastructure route first paint belongs to that same freshness discipline: +when websocket `state.resources` already carries the connected-system snapshot, +the route must keep that model visible immediately and revalidate richer REST +details only after the first-paint settle window instead of making the operator +wait on a blocking resource fetch before Pulse admits what is connected, or +forcing a second resource-shape transition while the summary/table shell is +still mounting. That same protected metrics-store boundary now also owns selected-series batch queries. Compact route consumers that request only CPU/memory or only storage `used`/`avail` capacity must keep that metric-type filter all the way through diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 418e48f07..7540a6203 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -370,6 +370,11 @@ bypass the API fail-closed execution gate. recovery consumers must also wait for the first canonical REST snapshot instead of painting thinner websocket transport rows first and then rehydrating into a richer canonical shape a moment later. + Websocket-first unified-resource hydration is an explicit consumer opt-in, + not the storage/recovery default. Infrastructure may use that opt-in for + connected-system continuity only with delayed canonical REST revalidation; + storage and recovery routes must continue to require canonical REST first + unless their own contract is updated with equivalent shape-stability proof. Shared chart transports in `internal/api/router.go` must follow the same rule in mock mode: `/api/storage-charts` and adjacent infrastructure chart payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index bb3a58952..dcb4e3d88 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -305,8 +305,13 @@ AI-only summary payloads, or page-local heuristics. refreshes. For default `initialHydration: 'immediate'` consumers, that same path must not paint the thinner websocket transport before the first canonical REST snapshot exists; - only explicit `prefer-ws` consumers may render directly from the realtime - transport before canonical hydrate completes. + only explicit websocket-first consumers may render directly from the realtime + transport before canonical hydrate completes. Operator surfaces that must + preserve already-known infrastructure continuity after login, such as the + Infrastructure page, must use websocket-first hydration with stale-cache + REST revalidation after the first-paint settle window so the page can paint + from live state immediately without forcing a second resource-shape + transition while summary and table surfaces are still mounting. Canonical cluster membership in that shared path must come only from explicit cluster identity such as Kubernetes context or platform cluster labels; standalone resource names must never be repurposed as synthetic diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 2b970d763..0d2587874 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -33,6 +33,7 @@ import infrastructureOperationsModelSource from '../infrastructureOperationsMode import infrastructureSourceManagerSource from '../InfrastructureSourceManager.tsx?raw'; import infrastructureSourcePickerSource from '../InfrastructureSourcePicker.tsx?raw'; import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw'; +import useConnectionsLedgerSource from '../useConnectionsLedger.ts?raw'; import agentProfileSettingsSource from '../agentProfileSettings.ts?raw'; import connectionsTableSource from '../ConnectionsTable.tsx?raw'; import monitoredSystemImpactPreviewSource from '../MonitoredSystemImpactPreview.tsx?raw'; @@ -591,6 +592,9 @@ describe('settings architecture guardrails', () => { expect(settingsHeaderMetaSource).toContain( "description: 'Configure the public URL, CORS, embedding, and webhook network boundaries.'", ); + expect(useConnectionsLedgerSource).toContain('createNonSuspendingQuery'); + expect(useConnectionsLedgerSource).toContain('pollMs: POLL_INTERVAL_MS'); + expect(useConnectionsLedgerSource).not.toContain('createResource'); }); it('keeps the detect-first editor and inline credential bodies on the shared editor model', () => { diff --git a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts index 9bea21e4e..25ec2b331 100644 --- a/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useConnectionsLedger.test.ts @@ -1,13 +1,26 @@ import { renderHook, waitFor } from '@solidjs/testing-library'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ConnectionsAPI, type Connection, type ConnectionSystem } from '@/api/connections'; +import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery'; import { useConnectionsLedger } from '../useConnectionsLedger'; +import useConnectionsLedgerSource from '../useConnectionsLedger.ts?raw'; describe('useConnectionsLedger', () => { + beforeEach(() => { + resetCreateNonSuspendingQueryCacheForTest(); + }); + afterEach(() => { + resetCreateNonSuspendingQueryCacheForTest(); vi.restoreAllMocks(); }); + it('keeps connection-ledger refreshes out of app-level Suspense', () => { + expect(useConnectionsLedgerSource).toContain('createNonSuspendingQuery'); + expect(useConnectionsLedgerSource).toContain('pollMs: POLL_INTERVAL_MS'); + expect(useConnectionsLedgerSource).not.toContain('createResource'); + }); + it('renders standalone agent rows with compact host identity and endpoint context', async () => { const connections: Connection[] = [ { @@ -126,6 +139,58 @@ describe('useConnectionsLedger', () => { expect(result.rows()[119]).toBe(firstRows[119]); }); + it('retains the fulfilled ledger while a reload is in flight', async () => { + const firstConnection: Connection = { + id: 'agent:tower', + type: 'agent', + name: 'Tower', + address: 'Tower', + state: 'active', + stateReason: '', + enabled: true, + surfaces: ['host'], + scope: { host: true }, + lastSeen: '2026-04-23T12:00:00Z', + lastError: null, + source: 'agent', + capabilities: { supportsPause: false, supportsScope: false, supportsTest: false }, + }; + const nextConnection: Connection = { + ...firstConnection, + id: 'agent:pi', + name: 'pi', + address: 'pi', + }; + let resolveReload: + | ((value: { connections: Connection[]; systems: ConnectionSystem[] }) => void) + | undefined; + vi.spyOn(ConnectionsAPI, 'list') + .mockResolvedValueOnce({ connections: [firstConnection], systems: [] }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveReload = resolve; + }), + ); + + const { result } = renderHook(() => useConnectionsLedger()); + + await waitFor(() => expect(result.rows()).toHaveLength(1)); + const firstRows = result.rows(); + expect(firstRows[0]?.name).toBe('Tower'); + + result.reload(); + + await waitFor(() => expect(result.loading()).toBe(true)); + expect(result.rows()).toBe(firstRows); + expect(result.rows()[0]?.name).toBe('Tower'); + + resolveReload?.({ connections: [nextConnection], systems: [] }); + + await waitFor(() => expect(result.loading()).toBe(false)); + await waitFor(() => expect(result.rows()[0]?.name).toBe('pi')); + }); + it('prioritizes explicit rollout drift, credential, and command-policy posture', async () => { const connections: Connection[] = [ { diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index b54efc208..9cfbb0109 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createResource, onCleanup } from 'solid-js'; +import { createMemo } from 'solid-js'; +import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation'; import { ConnectionsAPI, @@ -24,6 +25,7 @@ import { export { surfaceLabel }; const POLL_INTERVAL_MS = 15000; +const CONNECTIONS_LEDGER_QUERY_KEY = 'settings-infrastructure-connections'; export const CONNECTION_TYPE_LABELS: Record = { pve: 'Proxmox VE', @@ -443,6 +445,11 @@ interface ConnectionsLedgerSnapshot { systems: ConnectionSystem[]; } +const EMPTY_CONNECTIONS_LEDGER_SNAPSHOT: ConnectionsLedgerSnapshot = { + connections: [], + systems: [], +}; + export const useConnectionsLedger = (): ConnectionsLedger => { const rowCache = new Map(); @@ -474,30 +481,21 @@ export const useConnectionsLedger = (): ConnectionsLedger => { } }; - const [resource, { refetch }] = createResource( - async () => { + const ledgerSnapshot = createNonSuspendingQuery({ + source: () => CONNECTIONS_LEDGER_QUERY_KEY, + fetcher: async () => { const response = await ConnectionsAPI.list(); return { connections: response.connections ?? [], systems: response.systems ?? [], }; }, - { - initialValue: { - connections: [], - systems: [], - }, - }, - ); - - createEffect(() => { - const handle = window.setInterval(() => { - void refetch(); - }, POLL_INTERVAL_MS); - onCleanup(() => window.clearInterval(handle)); + initialValue: EMPTY_CONNECTIONS_LEDGER_SNAPSHOT, + cacheKey: (key) => key, + pollMs: POLL_INTERVAL_MS, }); - const snapshot = () => resource() ?? { connections: [], systems: [] }; + const snapshot = () => ledgerSnapshot.value() ?? EMPTY_CONNECTIONS_LEDGER_SNAPSHOT; const connections = () => snapshot().connections ?? []; const rows = createMemo(() => { const allConnections = connections(); @@ -546,9 +544,9 @@ export const useConnectionsLedger = (): ConnectionsLedger => { rows, findById, reload: () => { - void refetch(); + void ledgerSnapshot.refetch(); }, - loading: () => resource.loading, - error: () => resource.error, + loading: ledgerSnapshot.loading, + error: ledgerSnapshot.error, }; }; diff --git a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts index e48b81220..8428f1beb 100644 --- a/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts +++ b/frontend-modern/src/features/infrastructure/__tests__/InfrastructurePageSurface.guardrails.test.ts @@ -21,6 +21,7 @@ describe('InfrastructurePageSurface guardrails', () => { expect(infrastructurePageStateSource).toContain('useInfrastructurePageRouteState'); expect(infrastructurePageStateSource).toContain('buildInfrastructurePageFilterDerivation'); + expect(infrastructurePageStateSource).toContain("initialHydration: 'prefer-ws-then-rest'"); expect(infrastructurePageStateSource).not.toContain('useLocation('); expect(infrastructurePageStateSource).not.toContain('useNavigate('); expect(infrastructurePageStateSource).not.toContain('parseInfrastructureLinkSearch('); diff --git a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts index 2682e6756..573f3f317 100644 --- a/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts +++ b/frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts @@ -29,7 +29,9 @@ type DeployCluster = { }; export function useInfrastructurePageState() { - const { resources, loading, error, refetch } = useUnifiedResources(); + const { resources, loading, error, refetch } = useUnifiedResources({ + initialHydration: 'prefer-ws-then-rest', + }); const kioskMode = useKioskMode(); const { isMobile } = useBreakpoint(); diff --git a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index d2396e46e..a2bc23017 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -337,6 +337,67 @@ describe('useUnifiedResources', () => { dispose(); }); + it('uses an already-available websocket snapshot before the fallback REST timer for prefer-ws screens', async () => { + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ + query: '', + cacheKey: 'all-resources', + initialHydration: 'prefer-ws', + }); + }); + + await waitForResourceCount(() => result!.resources().length); + expect(result!.resources()[0]?.id).toBe(wsResource.id); + expect(result!.loading()).toBe(false); + expect(apiFetchMock).not.toHaveBeenCalled(); + + dispose(); + }); + + it('paints from websocket before revalidating prefer-ws-then-rest screens in the background', async () => { + let resolveFetch: + | ((value: { ok: true; json: () => Promise<{ data: Array }> }) => void) + | undefined; + apiFetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources({ + initialHydration: 'prefer-ws-then-rest', + }); + }); + + await waitForResourceCount(() => result!.resources().length); + await flushAsync(); + expect(result!.resources()[0]?.id).toBe(wsResource.id); + expect(result!.loading()).toBe(false); + expect(apiFetchMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(15_250); + await flushAsync(); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + + resolveFetch?.({ + ok: true, + json: async () => ({ data: [{ ...v2Resource, name: 'node-rest' }] }), + }); + + await waitForValue(() => result!.resources()[0]?.displayName, 'node-rest'); + expect(result!.loading()).toBe(false); + + dispose(); + }); + it('waits for the first canonical REST snapshot before painting immediate hydration screens', async () => { let resolveFetch: | ((value: { ok: true; json: () => Promise<{ data: Array }> }) => void) diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 947b19acb..9bd148386 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -46,6 +46,8 @@ const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000; const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800; const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500; const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200; +const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS = + UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250; type APIMetricValue = { value?: number; @@ -1029,10 +1031,12 @@ export const getCachedUnifiedResources = (options?: { return getUnifiedResourcesCacheEntry(scopedCacheKey).resources; }; +type UnifiedResourcesInitialHydration = 'immediate' | 'prefer-ws' | 'prefer-ws-then-rest'; + type UseUnifiedResourcesOptions = { query?: string; cacheKey?: string; - initialHydration?: 'immediate' | 'prefer-ws'; + initialHydration?: UnifiedResourcesInitialHydration; enabled?: Accessor; }; @@ -1044,7 +1048,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const typeFilter = parseUnifiedResourcesTypeFilter(query); const supportsCanonicalWsHydration = query === '' || typeFilter !== null; const prefersWsInitialHydration = - initialHydration === 'prefer-ws' && supportsCanonicalWsHydration; + (initialHydration === 'prefer-ws' || initialHydration === 'prefer-ws-then-rest') && + supportsCanonicalWsHydration; + const revalidatesRestAfterWsInitialHydration = + initialHydration === 'prefer-ws-then-rest' && supportsCanonicalWsHydration; const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID())); const resolveScopedCacheKey = () => buildScopedUnifiedResourcesCacheKey(cacheKey, orgScope()); let cacheEntry = seedUnifiedResourcesCacheFromAllResources( @@ -1066,6 +1073,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const wsStore = getGlobalWebSocketStore(); let refreshHandle: ReturnType | undefined; let initialHydrationHandle: ReturnType | undefined; + let canonicalRevalidationHandle: ReturnType | undefined; let inFlightRefetch: Promise | null = null; let wsInitialized = false; let lastWsUpdateToken = ''; @@ -1087,6 +1095,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const runRefetch = async (options?: { force?: boolean; source?: 'initial' | 'ws' | 'manual'; + background?: boolean; }) => { if (!enabled()) { return resources as unknown as Resource[]; @@ -1097,13 +1106,14 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const force = options?.force === true; const source = options?.source ?? 'manual'; + const background = options?.background === true; if (!force && source === 'ws' && shouldThrottleWsRefetch(cacheEntry)) { return resources as unknown as Resource[]; } const shouldForceNetwork = force || source === 'ws'; - const shouldShowLoading = force || !cacheEntry.hasSnapshot; + const shouldShowLoading = !background && (force || !cacheEntry.hasSnapshot); if (shouldShowLoading) { setLoading(true); } @@ -1126,7 +1136,9 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { }); return fetched; } catch (err) { - setError(err); + if (!background) { + setError(err); + } throw err; } finally { inFlightRefetch = null; @@ -1168,11 +1180,18 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } }; + const clearCanonicalRevalidationTimeout = () => { + if (canonicalRevalidationHandle !== undefined) { + clearTimeout(canonicalRevalidationHandle); + canonicalRevalidationHandle = undefined; + } + }; + const shouldPreferWsInitialHydration = () => - prefersWsInitialHydration && - !cacheEntry.hasSnapshot && - !wsStore.initialDataReceived() && - (!Array.isArray(wsStore.state.resources) || wsStore.state.resources.length === 0); + prefersWsInitialHydration && !cacheEntry.hasSnapshot; + + const hasWsInitialHydrationSnapshot = () => + wsStore.connected() && wsStore.initialDataReceived() && Array.isArray(wsStore.state.resources); const scheduleInitialHydrationFallback = () => { if (initialHydrationHandle !== undefined) { @@ -1207,11 +1226,29 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { }, delay); }; + const scheduleCanonicalRevalidation = () => { + if (canonicalRevalidationHandle !== undefined) { + return; + } + // Let websocket-first routes finish their initial summary/table mount + // before asking REST to enrich the thinner realtime snapshot. + canonicalRevalidationHandle = setTimeout(() => { + canonicalRevalidationHandle = undefined; + if (!enabled()) { + return; + } + void runRefetch({ source: 'initial', background: true }).catch((err) => { + logger.debug('[useUnifiedResources] Background canonical revalidation failed', err); + }); + }, UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS); + }; + createEffect(() => { orgScope(); if (!enabled()) { clearInitialHydrationTimeout(); + clearCanonicalRevalidationTimeout(); clearRefreshTimeout(); setLoading(false); return; @@ -1223,7 +1260,9 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } if (shouldPreferWsInitialHydration()) { - scheduleInitialHydrationFallback(); + if (!hasWsInitialHydrationSnapshot()) { + scheduleInitialHydrationFallback(); + } return; } @@ -1258,11 +1297,13 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const wsResources = Array.isArray(wsStore.state.resources) ? wsStore.state.resources : []; // For normal page loads, keep the first paint on the canonical REST contract. - // Only `prefer-ws` consumers are allowed to render directly from the thinner - // realtime transport before a canonical snapshot exists. + // Only explicit websocket-first consumers are allowed to render directly + // from the thinner realtime transport before a canonical snapshot exists. if (!cacheEntry.hasSnapshot && !prefersWsInitialHydration) { return; } + const shouldRevalidateCanonicalSnapshot = + revalidatesRestAfterWsInitialHydration && !cacheEntry.hasSnapshot; const allResourcesEntry = getUnifiedResourcesCacheEntry( buildScopedUnifiedResourcesCacheKey(ALL_RESOURCES_CACHE_KEY, currentOrgScope), ); @@ -1296,6 +1337,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { setError(undefined); setLoading(false); }); + + if (shouldRevalidateCanonicalSnapshot) { + scheduleCanonicalRevalidation(); + } }); createEffect(() => { @@ -1305,6 +1350,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { wsInitialized = false; lastWsUpdateToken = ''; clearInitialHydrationTimeout(); + clearCanonicalRevalidationTimeout(); clearRefreshTimeout(); return; } @@ -1357,6 +1403,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { wsInitialized = false; lastWsUpdateToken = ''; clearInitialHydrationTimeout(); + clearCanonicalRevalidationTimeout(); const scopedResources = cacheEntry.resources; const scopedPolicyPosture = cacheEntry.policyPosture; @@ -1379,6 +1426,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { onCleanup(() => { unsubscribeOrgSwitch(); clearInitialHydrationTimeout(); + clearCanonicalRevalidationTimeout(); clearRefreshTimeout(); });