From aea4a6042ff7ff9a426b1848f94f14edd1146ab8 Mon Sep 17 00:00:00 2001 From: cxymds Date: Sun, 5 Jul 2026 17:11:35 +0800 Subject: [PATCH] perf: optimize login first screen --- app/(auth)/auth/login/page.tsx | 46 +++++++++---- app/(auth)/auth/oidc-callback/page.tsx | 8 +-- app/(auth)/config/page.tsx | 3 +- app/(dashboard)/layout.tsx | 38 +++++++---- app/layout.tsx | 14 +--- components/app-loading-shell.tsx | 42 ++++++++++++ components/language-switcher.tsx | 4 +- components/providers/i18n-provider.tsx | 3 +- contexts/auth-context.tsx | 2 +- lib/i18n.ts | 75 ++++++++++++++++++++-- lib/routes.ts | 13 +++- tests/lib/login-performance-source.test.js | 65 +++++++++++++++++++ tests/lib/routes-source.test.js | 19 ++++++ 13 files changed, 275 insertions(+), 57 deletions(-) create mode 100644 components/app-loading-shell.tsx create mode 100644 tests/lib/login-performance-source.test.js create mode 100644 tests/lib/routes-source.test.js diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx index 07314fdc..91724c64 100644 --- a/app/(auth)/auth/login/page.tsx +++ b/app/(auth)/auth/login/page.tsx @@ -5,8 +5,8 @@ import { useSearchParams } from "next/navigation" import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" import { LoginForm, type LoginMethod } from "@/components/auth/login-form" +import { AppLoadingShell } from "@/components/app-loading-shell" import { useAuth } from "@/contexts/auth-context" -import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route" import { useMessage } from "@/lib/feedback/message" import { configManager } from "@/lib/config" import { fetchOidcProviders, initiateOidcLogin } from "@/lib/oidc" @@ -14,7 +14,7 @@ import type { OidcProvider } from "@/types/config" export default function LoginPage() { return ( - }> + }> ) @@ -25,7 +25,6 @@ function LoginPageContent() { const searchParams = useSearchParams() const message = useMessage() const { login, isAuthenticated } = useAuth() - const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute() const { t } = useTranslation() const [method, setMethod] = useState("accessKeyAndSecretKey") @@ -41,9 +40,9 @@ function LoginPageContent() { const [oidcProviders, setOidcProviders] = useState([]) useEffect(() => { - if (!isAuthenticated || !hasResolvedFirstRoute || !firstAccessibleRoute) return - router.replace(firstAccessibleRoute) - }, [isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router]) + if (!isAuthenticated) return + router.replace("/") + }, [isAuthenticated, router]) useEffect(() => { if (searchParams.get("unauthorized") === "true") { @@ -52,13 +51,38 @@ function LoginPageContent() { } }, [searchParams, message, t, router]) - // Fetch OIDC providers on mount + // Fetch OIDC providers after the first paint path has had a chance to settle. useEffect(() => { - configManager.loadConfig().then((config) => { - if (config?.serverHost) { - fetchOidcProviders(config.serverHost).then(setOidcProviders) + let cancelled = false + const loadOidcProviders = () => { + configManager.loadConfig().then((config) => { + if (cancelled) return + if (config?.serverHost) { + fetchOidcProviders(config.serverHost).then((providers) => { + if (!cancelled) setOidcProviders(providers) + }) + } + }) + } + + const idleWindow = window as Window & { + requestIdleCallback?: (callback: IdleRequestCallback) => number + cancelIdleCallback?: (handle: number) => void + } + + if (typeof idleWindow.requestIdleCallback === "function") { + const idleId = idleWindow.requestIdleCallback(loadOidcProviders) + return () => { + cancelled = true + idleWindow.cancelIdleCallback?.(idleId) } - }) + } + + const timerId = window.setTimeout(loadOidcProviders, 0) + return () => { + cancelled = true + window.clearTimeout(timerId) + } }, []) const handleLogin = async (e: React.FormEvent) => { diff --git a/app/(auth)/auth/oidc-callback/page.tsx b/app/(auth)/auth/oidc-callback/page.tsx index 8d333cd6..039e6ec3 100644 --- a/app/(auth)/auth/oidc-callback/page.tsx +++ b/app/(auth)/auth/oidc-callback/page.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef, useState } from "react" import { useRouter } from "next/navigation" import { useAuth } from "@/contexts/auth-context" -import { useFirstAccessibleDashboardRoute } from "@/hooks/use-first-accessible-dashboard-route" import { useMessage } from "@/lib/feedback/message" import { parseOidcCallback } from "@/lib/oidc" import { isSafeRedirectPath } from "@/lib/routes" @@ -12,7 +11,6 @@ import { useTranslation } from "react-i18next" export default function OidcCallbackPage() { const router = useRouter() const { loginWithStsCredentials, isAuthenticated } = useAuth() - const { route: firstAccessibleRoute, isReady: hasResolvedFirstRoute } = useFirstAccessibleDashboardRoute() const message = useMessage() const { t } = useTranslation() const processed = useRef(false) @@ -63,10 +61,8 @@ export default function OidcCallbackPage() { return } - if (hasResolvedFirstRoute && firstAccessibleRoute) { - router.replace(firstAccessibleRoute) - } - }, [credentialsSet, isAuthenticated, hasResolvedFirstRoute, firstAccessibleRoute, router]) + router.replace("/") + }, [credentialsSet, isAuthenticated, router]) return (
diff --git a/app/(auth)/config/page.tsx b/app/(auth)/config/page.tsx index 352cd98b..d57e2df7 100644 --- a/app/(auth)/config/page.tsx +++ b/app/(auth)/config/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import Image from "next/image" import { useRouter } from "next/navigation" import { useTranslation } from "react-i18next" +import { AppLoadingShell } from "@/components/app-loading-shell" import { Button } from "@/components/ui/button" import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" @@ -20,7 +21,7 @@ import { getThemeManifest } from "@/lib/theme/manifest" export default function ConfigPage() { return ( - }> + }> ) diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index b4e12a58..1eca4f02 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -2,21 +2,33 @@ import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar" import { AppSidebar } from "@/components/app-sidebar" import { AppTopNav } from "@/components/app-top-nav" import { DashboardAuthGuard } from "@/components/dashboard-auth-guard" +import { ApiProvider } from "@/contexts/api-context" +import { S3Provider } from "@/contexts/s3-context" +import { TaskProvider } from "@/contexts/task-context" +import { PermissionsProvider } from "@/hooks/use-permissions" export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( - - - - -
- -
- {children} -
-
-
-
-
+ + + + + + + + +
+ +
+ {children} +
+
+
+
+
+
+
+
+
) } diff --git a/app/layout.tsx b/app/layout.tsx index 9ab28918..4cfe9a29 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,10 +3,6 @@ import "./globals.css" import { ThemeProvider } from "@/components/providers/theme-provider" import { I18nProvider } from "@/components/providers/i18n-provider" import { AuthProvider } from "@/contexts/auth-context" -import { ApiProvider } from "@/contexts/api-context" -import { S3Provider } from "@/contexts/s3-context" -import { TaskProvider } from "@/contexts/task-context" -import { PermissionsProvider } from "@/hooks/use-permissions" import { AppUiProvider } from "@/components/providers/app-ui-provider" import { getThemeManifest } from "@/lib/theme/manifest" @@ -28,15 +24,7 @@ export default function RootLayout({ - - - - - {children} - - - - + {children} diff --git a/components/app-loading-shell.tsx b/components/app-loading-shell.tsx new file mode 100644 index 00000000..c67970bf --- /dev/null +++ b/components/app-loading-shell.tsx @@ -0,0 +1,42 @@ +"use client" + +export function AppLoadingShell() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index bbef183d..e22ca52d 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next" import { RiTranslate2 } from "@remixicon/react" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { LOCALE_CODES, type Locale } from "@/lib/i18n" +import { changeLocale, LOCALE_CODES, type Locale } from "@/lib/i18n" const languageConfig: Record = { en: { text: "English", icon: RiTranslate2 }, @@ -46,7 +46,7 @@ export function LanguageSwitcher() { /> {options.map(({ label, key }) => ( - i18n.changeLanguage(key)}> + void changeLocale(key)}> {label} ))} diff --git a/components/providers/i18n-provider.tsx b/components/providers/i18n-provider.tsx index a8f93595..70be6d7b 100644 --- a/components/providers/i18n-provider.tsx +++ b/components/providers/i18n-provider.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react" import { DEFAULT_LOCALE, initI18n, isRtlLocale, normalizeLocale } from "@/lib/i18n" import { DirectionProvider } from "@/components/ui/direction" +import { AppLoadingShell } from "@/components/app-loading-shell" import i18n from "i18next" type Dir = "ltr" | "rtl" @@ -50,7 +51,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { }, []) if (!ready) { - return
+ return } return {children} diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index 693f1909..84d8c70f 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -2,7 +2,6 @@ import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react" import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@aws-sdk/types" -import { getStsToken } from "@/lib/sts" import type { SiteConfig } from "@/types/config" import { getLoginRoute } from "@/lib/routes" import { useLocalStorage } from "@/hooks/use-local-storage" @@ -95,6 +94,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { customConfig = await configManager.loadConfig() } + const { getStsToken } = await import("@/lib/sts") const credentialsResponse = await getStsToken(credentials, "arn:aws:iam::*:role/Admin", customConfig) setCredentials({ diff --git a/lib/i18n.ts b/lib/i18n.ts index 7b91aa5d..b298583d 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -4,14 +4,62 @@ import i18n from "i18next" import { initReactI18next } from "react-i18next" import LanguageDetector from "i18next-browser-languagedetector" import { loadThemeLocaleOverride } from "@/lib/theme/locales" -import { DEFAULT_LOCALE, LOCALE_CODES, LOCALE_FILE_MAP, normalizeLocale } from "@/lib/i18n-config" +import { DEFAULT_LOCALE, LOCALE_CODES, LOCALE_FILE_MAP, normalizeLocale, type Locale } from "@/lib/i18n-config" const loadLocale = async (file: string) => { const mod = await import(`@/i18n/locales/${file}.json`) - return mod.default + return mod.default as Record } let initPromise: Promise | null = null +const loadedLocales = new Set() + +function getCookieValue(name: string): string | undefined { + if (typeof document === "undefined") return undefined + + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const match = document.cookie.match(new RegExp(`(?:^|; )${escapedName}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : undefined +} + +function detectInitialLocale(): Locale { + if (typeof window === "undefined") return DEFAULT_LOCALE + + const queryLocale = new URLSearchParams(window.location.search).get("lang") + if (queryLocale) return normalizeLocale(queryLocale) + + const legacyStorage = window.localStorage.getItem("i18n_redirected") ?? window.localStorage.getItem("lang") + if (legacyStorage) return normalizeLocale(legacyStorage) + + const cookieLocale = getCookieValue("i18n_redirected") + if (cookieLocale) return normalizeLocale(cookieLocale) + + const navigatorLocale = window.navigator.languages?.[0] ?? window.navigator.language + if (navigatorLocale) return normalizeLocale(navigatorLocale) + + return normalizeLocale(document.documentElement.lang) +} + +async function loadLocaleResource(locale: Locale): Promise> { + const file = LOCALE_FILE_MAP[locale] + const [baseLocale, themeOverride] = await Promise.all([loadLocale(file), loadThemeLocaleOverride(file)]) + return themeOverride ? { ...baseLocale, ...themeOverride } : baseLocale +} + +export async function ensureLocaleResources(localeInput: string | null | undefined): Promise { + const locale = normalizeLocale(localeInput) + if (loadedLocales.has(locale) && i18n.hasResourceBundle(locale, "translation")) return locale + + const resources = await loadLocaleResource(locale) + i18n.addResourceBundle(locale, "translation", resources, true, true) + loadedLocales.add(locale) + return locale +} + +export async function changeLocale(localeInput: string | null | undefined): Promise { + const locale = await ensureLocaleResources(localeInput) + await i18n.changeLanguage(locale) +} function createLanguageDetector() { const detector = new LanguageDetector() @@ -40,14 +88,21 @@ export async function initI18n() { if (initPromise) return initPromise initPromise = (async () => { + const initialLocale = detectInitialLocale() + const initialLocales = [...new Set([DEFAULT_LOCALE, initialLocale])] const resources: Record }> = {} - for (const [code, file] of Object.entries(LOCALE_FILE_MAP)) { - const baseLocale = await loadLocale(file) - const themeOverride = await loadThemeLocaleOverride(file) + await Promise.all( + initialLocales.map(async (code) => { + const translation = await loadLocaleResource(code) + loadedLocales.add(code) + resources[code] = { translation } + }), + ) + for (const code of initialLocales) { resources[code] = { - translation: themeOverride ? { ...baseLocale, ...themeOverride } : baseLocale, + translation: resources[code].translation, } } @@ -58,6 +113,7 @@ export async function initI18n() { .use(initReactI18next) .init({ resources, + lng: initialLocale, fallbackLng: DEFAULT_LOCALE, supportedLngs: LOCALE_CODES, nonExplicitSupportedLngs: true, @@ -81,7 +137,12 @@ export async function initI18n() { return i18n })() - return initPromise + try { + return await initPromise + } catch (error) { + initPromise = null + throw error + } } export { DEFAULT_LOCALE, LOCALE_CODES, RTL_LOCALES, isRtlLocale, normalizeLocale, type Locale } from "@/lib/i18n-config" diff --git a/lib/routes.ts b/lib/routes.ts index 6a89d217..d854e557 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -28,6 +28,13 @@ export function getLoginRoute(): string { return buildRoute("/auth/login") } +function stripBasePath(path: string): string { + if (!BASE_PATH) return path + if (path === BASE_PATH) return "/" + if (path.startsWith(`${BASE_PATH}/`)) return path.slice(BASE_PATH.length) || "/" + return path +} + /** * Validate that a redirect path is safe (relative, no protocol, no external domain). * Returns the path if safe, or the fallback otherwise. @@ -53,8 +60,10 @@ export function isSafeRedirectPath(path: string, fallback = "/"): string { // Ensure it starts with / (relative path) if (!trimmed.startsWith("/")) return fallback + const appPath = stripBasePath(trimmed) + // Block directory traversal sequences (e.g. /../evil.com, /../../etc/passwd) - if (/(?:^|\/)\.\.(?:\/|$)/.test(trimmed)) return fallback + if (/(?:^|\/)\.\.(?:\/|$)/.test(appPath)) return fallback - return trimmed + return appPath } diff --git a/tests/lib/login-performance-source.test.js b/tests/lib/login-performance-source.test.js new file mode 100644 index 00000000..9a25d593 --- /dev/null +++ b/tests/lib/login-performance-source.test.js @@ -0,0 +1,65 @@ +import test from "node:test" +import assert from "node:assert/strict" +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 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") +const i18nSource = fs.readFileSync("lib/i18n.ts", "utf8") +const loadingShell = fs.readFileSync("components/app-loading-shell.tsx", "utf8") + +test("root layout does not load dashboard-only providers on auth routes", () => { + assert.doesNotMatch(rootLayout, /@\/contexts\/api-context/) + assert.doesNotMatch(rootLayout, /@\/contexts\/s3-context/) + assert.doesNotMatch(rootLayout, /@\/contexts\/task-context/) + assert.doesNotMatch(rootLayout, /@\/hooks\/use-permissions/) + assert.doesNotMatch(rootLayout, //) + assert.doesNotMatch(rootLayout, //) + assert.doesNotMatch(rootLayout, //) + assert.doesNotMatch(rootLayout, //) +}) + +test("dashboard layout owns dashboard providers before the auth guard", () => { + for (const symbol of ["ApiProvider", "S3Provider", "TaskProvider", "PermissionsProvider", "DashboardAuthGuard"]) { + assert.match(dashboardLayout, new RegExp(symbol)) + } + + assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf("")) + assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf("")) + assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf("")) + assert.ok(dashboardLayout.indexOf("") < dashboardLayout.indexOf("")) +}) + +test("auth routes do not depend on dashboard permission resolution", () => { + assert.doesNotMatch(loginPage, /useFirstAccessibleDashboardRoute/) + assert.doesNotMatch(oidcCallbackPage, /useFirstAccessibleDashboardRoute/) +}) + +test("STS client is loaded only when login is submitted", () => { + assert.doesNotMatch(authContext, /import\s+\{\s*getStsToken\s*\}\s+from\s+["']@\/lib\/sts["']/) + assert.match(authContext, /await import\(["']@\/lib\/sts["']\)/) +}) + +test("login and i18n fallbacks render a visible loading shell", () => { + assert.match(loginPage, /fallback=\{\}/) + assert.match(i18nProvider, /return /) + assert.doesNotMatch(loginPage, /fallback=\{
\}/) + assert.doesNotMatch(i18nProvider, /return
/) + assert.match(loadingShell, /animate-pulse/) +}) + +test("OIDC provider discovery is deferred off the first render path", () => { + assert.match(loginPage, /requestIdleCallback/) + assert.doesNotMatch(loginPage, /fetchOidcProviders\(config\.serverHost\)\.then\(setOidcProviders\)/) +}) + +test("i18n initializes only the detected locale plus fallback and lazy-loads later languages", () => { + assert.match(i18nSource, /function detectInitialLocale/) + assert.match(i18nSource, /const initialLocales = \[\.\.\.new Set\(\[DEFAULT_LOCALE, initialLocale\]\)\]/) + assert.match(i18nSource, /export async function ensureLocaleResources/) + assert.match(i18nSource, /export async function changeLocale/) + assert.doesNotMatch(i18nSource, /for \(const \[code, file\] of Object\.entries\(LOCALE_FILE_MAP\)\)/) +}) diff --git a/tests/lib/routes-source.test.js b/tests/lib/routes-source.test.js new file mode 100644 index 00000000..1f41e602 --- /dev/null +++ b/tests/lib/routes-source.test.js @@ -0,0 +1,19 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" + +const routesSource = fs.readFileSync("lib/routes.ts", "utf8") +const oidcSource = fs.readFileSync("lib/oidc.ts", "utf8") + +test("safe redirects normalize the configured base path before router navigation", () => { + assert.match(routesSource, /function stripBasePath\(path: string\): string/) + assert.match(routesSource, /if \(path === BASE_PATH\) return "\/"/) + assert.match(routesSource, /path\.startsWith\(`\$\{BASE_PATH\}\/`\)/) + assert.match(routesSource, /const appPath = stripBasePath\(trimmed\)/) + assert.match(routesSource, /return appPath/) +}) + +test("OIDC callback continues to validate redirect paths through the shared route guard", () => { + assert.match(oidcSource, /import \{ isSafeRedirectPath \} from "@\/lib\/routes"/) + assert.match(oidcSource, /redirect: isSafeRedirectPath\(params\.get\("redirect"\) \?\? "", "\/"\)/) +})