diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d27b74b0f..a26d8fe51 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -375,6 +375,10 @@ bypass the API fail-closed execution gate. 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. + The shared `useUnifiedResources()` scope lifecycle is also a stability + boundary for storage/recovery consumers: org-scope or enabled-state changes + must invalidate stale in-flight REST refreshes before their errors or + request-guard cleanup can leak into the active resource snapshot. 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 2dc6d82e7..805bf1c3b 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -327,6 +327,11 @@ AI-only summary payloads, or page-local heuristics. 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. + Org-scope and enabled-state transitions in + `frontend-modern/src/hooks/useUnifiedResources.ts` must invalidate older + in-flight REST refreshes before publishing the new scoped cache entry, so a + stale request cannot set active-scope errors, clear the active request guard, + or replace the currently mounted Infrastructure/Workloads resource snapshot. 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/hooks/__tests__/useUnifiedResources.test.ts b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts index a2bc23017..2ab86541f 100644 --- a/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts +++ b/frontend-modern/src/hooks/__tests__/useUnifiedResources.test.ts @@ -119,6 +119,22 @@ const waitForValue = async (readValue: () => T, expected: T) => { throw new Error(`Timed out waiting for expected value: ${String(expected)}`); }; +const deferred = () => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const resourceResponse = (data: unknown[]) => ({ + ok: true, + status: 200, + json: async () => ({ data }), +}); + describe('useUnifiedResources', () => { let apiFetchMock: ReturnType; let setWsState: SetStoreFunction; @@ -1724,6 +1740,55 @@ describe('useUnifiedResources', () => { dispose(); }); + it('ignores stale refetch errors and keeps the active scope request guard', async () => { + const staleDefaultRequest = deferred>(); + const activeTenantRequest = deferred>(); + apiFetchMock + .mockImplementationOnce(() => staleDefaultRequest.promise) + .mockImplementationOnce(() => activeTenantRequest.promise); + + let dispose = () => {}; + let result: ReturnType | undefined; + createRoot((d) => { + dispose = d; + result = useUnifiedResources(); + }); + + await flushAsync(); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(result!.loading()).toBe(true); + + eventBus.emit('org_switched', 'tenant-b'); + await flushAsync(); + expect(apiFetchMock).toHaveBeenCalledTimes(2); + + staleDefaultRequest.reject(new Error('stale default-scope refresh failed')); + await flushAsync(); + + expect(result!.error()).toBeUndefined(); + expect(result!.loading()).toBe(true); + + void result!.refetch().catch(() => undefined); + await flushAsync(); + expect(apiFetchMock).toHaveBeenCalledTimes(2); + + activeTenantRequest.resolve( + resourceResponse([ + { + ...v2Resource, + id: 'tenant-node', + name: 'tenant-node', + }, + ]), + ); + await waitForValue(() => result!.resources()[0]?.id, 'tenant-node'); + + expect(result!.error()).toBeUndefined(); + expect(result!.loading()).toBe(false); + + dispose(); + }); + it('normalizes proxmox service aliases into canonical platform types', async () => { apiFetchMock.mockResolvedValueOnce({ ok: true, diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index 9bd148386..abfd4a1d1 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -1121,13 +1121,16 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { const requestVersion = scopeVersion; const entryForRequest = cacheEntry; const request = (async () => { + const isCurrentRequest = () => + requestVersion === scopeVersion && entryForRequest === cacheEntry && enabled(); + try { const fetched = await fetchUnifiedResourcesShared( entryForRequest, query, shouldForceNetwork, ); - if (requestVersion !== scopeVersion || entryForRequest !== cacheEntry) { + if (!isCurrentRequest()) { return resources as unknown as Resource[]; } batch(() => { @@ -1136,13 +1139,15 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { }); return fetched; } catch (err) { - if (!background) { + if (!background && isCurrentRequest()) { setError(err); } throw err; } finally { - inFlightRefetch = null; - if (shouldShowLoading) { + if (inFlightRefetch === request) { + inFlightRefetch = null; + } + if (shouldShowLoading && isCurrentRequest()) { setLoading(false); } } @@ -1247,6 +1252,8 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { orgScope(); if (!enabled()) { + scopeVersion += 1; + inFlightRefetch = null; clearInitialHydrationTimeout(); clearCanonicalRevalidationTimeout(); clearRefreshTimeout(); @@ -1389,21 +1396,24 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) { } scopeVersion += 1; - blockedWsHydrationToken = supportsCanonicalWsHydration - ? String(wsStore.state.lastUpdate ?? '') - : null; - setOrgScope(nextOrgScope); - cacheEntry = seedUnifiedResourcesCacheFromAllResources( - getUnifiedResourcesCacheEntry(resolveScopedCacheKey()), + const nextCacheEntry = seedUnifiedResourcesCacheFromAllResources( + getUnifiedResourcesCacheEntry( + buildScopedUnifiedResourcesCacheKey(cacheKey, nextOrgScope), + ), cacheKey, query, nextOrgScope, ); + blockedWsHydrationToken = supportsCanonicalWsHydration + ? String(wsStore.state.lastUpdate ?? '') + : null; + cacheEntry = nextCacheEntry; inFlightRefetch = null; wsInitialized = false; lastWsUpdateToken = ''; clearInitialHydrationTimeout(); clearCanonicalRevalidationTimeout(); + setOrgScope(nextOrgScope); const scopedResources = cacheEntry.resources; const scopedPolicyPosture = cacheEntry.policyPosture;