mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Stabilize infrastructure resource hydration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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<ConnectionType, string> = {
|
||||
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<string, CachedInfrastructureSystemRow>();
|
||||
|
||||
@@ -474,30 +481,21 @@ export const useConnectionsLedger = (): ConnectionsLedger => {
|
||||
}
|
||||
};
|
||||
|
||||
const [resource, { refetch }] = createResource<ConnectionsLedgerSnapshot>(
|
||||
async () => {
|
||||
const ledgerSnapshot = createNonSuspendingQuery<ConnectionsLedgerSnapshot, string>({
|
||||
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<InfrastructureSystemRow[]>(() => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
+1
@@ -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(');
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<UseUnifiedResourcesModule['useUnifiedResources']> | 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<typeof v2Resource> }> }) => void)
|
||||
| undefined;
|
||||
apiFetchMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let dispose = () => {};
|
||||
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | 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<typeof v2Resource> }> }) => void)
|
||||
|
||||
@@ -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<boolean>;
|
||||
};
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
let initialHydrationHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let canonicalRevalidationHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlightRefetch: Promise<Resource[]> | 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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user