diff --git a/contexts/api-context.tsx b/contexts/api-context.tsx index e112428..82d635a 100644 --- a/contexts/api-context.tsx +++ b/contexts/api-context.tsx @@ -1,6 +1,6 @@ "use client" -import { createContext, useContext, useEffect, useState } from "react" +import { createContext, useContext, useEffect, useRef, useState } from "react" import { usePathname, useRouter } from "next/navigation" import { ApiClient } from "@/lib/api-client" import { AwsClient } from "@/lib/aws4fetch" @@ -22,6 +22,11 @@ export function ApiProvider({ children }: { children: React.ReactNode }) { const [isReady, setIsReady] = useState(false) const router = useRouter() const pathname = usePathname() + const pathnameRef = useRef(pathname) + + useEffect(() => { + pathnameRef.current = pathname + }, [pathname]) useEffect(() => { if (!isAuthenticated || !credentials?.AccessKeyId) { @@ -65,7 +70,7 @@ export function ApiProvider({ children }: { children: React.ReactNode }) { return } - if (pathname === "/403/") return + if (pathnameRef.current === "/403/") return router.replace("/403/") }, }) @@ -89,7 +94,6 @@ export function ApiProvider({ children }: { children: React.ReactNode }) { credentials?.SecretAccessKey, credentials?.SessionToken, logout, - pathname, router, ]) diff --git a/hooks/use-performance-data.ts b/hooks/use-performance-data.ts index 03aca39..35e44a4 100644 --- a/hooks/use-performance-data.ts +++ b/hooks/use-performance-data.ts @@ -3,6 +3,7 @@ import * as React from "react" import { useCallback, useEffect, useState } from "react" import { useSystem } from "@/hooks/use-system" +import { scheduleMicrotask } from "@/lib/schedule-microtask" import { normalizeDataUsageInfo, normalizeMetricsInfo, @@ -157,6 +158,7 @@ export function usePerformanceData() { }, [getDataUsageInfo, getStorageInfo, getSystemInfo, getSystemMetrics]) useEffect(() => { + let cancelled = false mountedRef.current = true hasSystemDataRef.current = false setSystemInfo({}) @@ -169,7 +171,9 @@ export function usePerformanceData() { setSourceErrors({}) setLastUpdatedAt(null) setMetricsUpdatedAt(null) - void refetch() + scheduleMicrotask(() => { + if (!cancelled) void refetch() + }) const interval = window.setInterval(() => { if (document.visibilityState === "visible") void refetch() @@ -181,6 +185,7 @@ export function usePerformanceData() { document.addEventListener("visibilitychange", handleVisibilityChange) return () => { + cancelled = true mountedRef.current = false requestVersionRef.current += 1 abortControllerRef.current?.abort() diff --git a/hooks/use-permissions.tsx b/hooks/use-permissions.tsx index 064de69..45f3a99 100644 --- a/hooks/use-permissions.tsx +++ b/hooks/use-permissions.tsx @@ -5,6 +5,7 @@ import { hasConsoleScopes, type ConsolePolicy } from "@/lib/console-policy-parse import { hasConsoleCapability, type ConsoleCapability } from "@/lib/permission-capabilities" import type { PermissionResourceContext } from "@/lib/permission-resources" import { CONSOLE_SCOPES, PAGE_PERMISSIONS } from "@/lib/console-permissions" +import { scheduleMicrotask } from "@/lib/schedule-microtask" import { useAuth } from "@/contexts/auth-context" import { useApiOptional } from "@/contexts/api-context" @@ -130,8 +131,15 @@ export function PermissionsProvider({ children }: { children: React.ReactNode }) ) useEffect(() => { + let cancelled = false if (api && isAuthenticated && !hasResolvedAdmin) { - void fetchAdminStatus() + scheduleMicrotask(() => { + if (!cancelled) void fetchAdminStatus() + }) + } + + return () => { + cancelled = true } }, [api, isAuthenticated, hasResolvedAdmin, fetchAdminStatus]) diff --git a/tests/lib/login-performance-source.test.js b/tests/lib/login-performance-source.test.js index 9a25d59..0eb208d 100644 --- a/tests/lib/login-performance-source.test.js +++ b/tests/lib/login-performance-source.test.js @@ -5,6 +5,7 @@ import fs from "node:fs" const rootLayout = fs.readFileSync("app/layout.tsx", "utf8") const dashboardLayout = fs.readFileSync("app/(dashboard)/layout.tsx", "utf8") const authContext = fs.readFileSync("contexts/auth-context.tsx", "utf8") +const apiContext = fs.readFileSync("contexts/api-context.tsx", "utf8") const loginPage = fs.readFileSync("app/(auth)/auth/login/page.tsx", "utf8") const oidcCallbackPage = fs.readFileSync("app/(auth)/auth/oidc-callback/page.tsx", "utf8") const i18nProvider = fs.readFileSync("components/providers/i18n-provider.tsx", "utf8") @@ -33,6 +34,17 @@ test("dashboard layout owns dashboard providers before the auth guard", () => { assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf("")) }) +test("dashboard navigation does not recreate the API client", () => { + const apiSetupEffect = apiContext.match( + /useEffect\(\(\) => \{\n if \(!isAuthenticated[\s\S]*?\n \}, \[([\s\S]*?)\]\)/, + ) + + assert.ok(apiSetupEffect) + assert.doesNotMatch(apiSetupEffect[1], /pathname/) + assert.match(apiContext, /pathnameRef\.current = pathname/) + assert.match(apiContext, /pathnameRef\.current === "\/403\/"/) +}) + test("auth routes do not depend on dashboard permission resolution", () => { assert.doesNotMatch(loginPage, /useFirstAccessibleDashboardRoute/) assert.doesNotMatch(oidcCallbackPage, /useFirstAccessibleDashboardRoute/) diff --git a/tests/lib/running-status-safety.test.js b/tests/lib/running-status-safety.test.js index 9f85a19..ff63e71 100644 --- a/tests/lib/running-status-safety.test.js +++ b/tests/lib/running-status-safety.test.js @@ -4,6 +4,7 @@ import fs from "node:fs" const hookSource = fs.readFileSync("hooks/use-performance-data.ts", "utf8") const systemSource = fs.readFileSync("hooks/use-system.ts", "utf8") +const permissionsSource = fs.readFileSync("hooks/use-permissions.tsx", "utf8") const pageSource = fs.readFileSync("app/(dashboard)/status/page.tsx", "utf8") const serverSource = fs.readFileSync("app/(dashboard)/_components/performance-server-list.tsx", "utf8") const usageSource = fs.readFileSync("app/(dashboard)/_components/performance-usage-card.tsx", "utf8") @@ -26,6 +27,12 @@ test("performance refresh keeps partial data and rejects stale responses", () => assert.ok((systemSource.match(/suppress403Redirect: true/g) ?? []).length >= 3) }) +test("running status skips Strict Mode preview requests", () => { + assert.match(hookSource, /scheduleMicrotask\(\(\) => \{\s*if \(!cancelled\) void refetch\(\)/) + assert.match(hookSource, /return \(\) => \{\s*cancelled = true/) + assert.match(permissionsSource, /scheduleMicrotask\(\(\) => \{\s*if \(!cancelled\) void fetchAdminStatus\(\)/) +}) + test("running status distinguishes unknown values and exposes recovery feedback", () => { assert.match(pageSource, /role="alert"/) assert.match(pageSource, /aria-busy=\{refreshing\}/)