From b1713a98204058e5592285d03b826ea45da44f79 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 8 Aug 2026 12:40:48 +0200 Subject: [PATCH] fix: revert Suspense layout wrappers causing E2E hydration errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert layout.tsx, _layout-creator.tsx, manage/layout.tsx, _theme.tsx, _creator.tsx, mantine.tsx, and login/page.tsx to origin/dev — the Suspense provider splits caused "EditMode is required", stream close, and React #419 errors. Also remove cacheComponents/cacheHandlers from next.config.ts (incompatible with auth/i18n/dynamic rendering) and fix Boolean() truthy bug in Redis connection. --- apps/nextjs/next.config.ts | 10 -- .../[locale]/_client-providers/mantine.tsx | 4 +- .../[locale]/_session-scoped-providers.tsx | 81 --------- .../src/app/[locale]/auth/login/page.tsx | 25 +-- .../[locale]/boards/(content)/_creator.tsx | 89 ++++------ .../app/[locale]/boards/(content)/_theme.tsx | 4 +- .../app/[locale]/boards/_layout-creator.tsx | 164 +++++++----------- apps/nextjs/src/app/[locale]/layout.tsx | 114 ++++++++++-- .../nextjs/src/app/[locale]/manage/layout.tsx | 37 ++-- packages/redis/src/lib/connection.ts | 14 +- 10 files changed, 221 insertions(+), 321 deletions(-) delete mode 100644 apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 7428f7e52..f1dee9f53 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -16,9 +16,6 @@ const withNextIntl = createNextIntlPlugin({ }); const nextConfig: NextConfig = { - cacheHandlers: { - default: path.resolve(import.meta.dirname, "../../packages/next-cache-handler/src/index.ts"), - }, // Next previews otherwise create agent instruction files in the application // directory during development. agentRules: false, @@ -27,13 +24,6 @@ const nextConfig: NextConfig = { }, output: "standalone", reactStrictMode: true, - cacheComponents: true, - cacheMaxMemorySize: 32 * 1024 * 1024, - cacheLife: { - config: { stale: 60, revalidate: 60 * 60 * 24, expire: 60 * 60 * 24 * 7 }, - integration: { stale: 15, revalidate: 60, expire: 60 * 10 }, - session: { stale: 30, revalidate: 60 * 5, expire: 60 * 30 }, - }, // react compiler breaks mantine-react-table, so disabled for now //reactCompiler: true, /** We already do typechecking as separate tasks in CI */ diff --git a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx index e5e423102..493d368f1 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/mantine.tsx @@ -13,8 +13,8 @@ import { theme } from "@homarr/ui"; export const CustomMantineProvider = ({ children, - defaultColorScheme = "auto", -}: PropsWithChildren<{ defaultColorScheme?: MantineColorScheme }>) => { + defaultColorScheme, +}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => { const manager = useColorSchemeManager(); return ( diff --git a/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx b/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx deleted file mode 100644 index 06e6bc203..000000000 --- a/apps/nextjs/src/app/[locale]/_session-scoped-providers.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { PropsWithChildren } from "react"; -import type { DayOfWeek } from "@mantine/dates"; -import { NextIntlClientProvider } from "next-intl"; - -import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; -import { getRscUserSettingsAsync } from "@homarr/api/user-server"; -import { env } from "@homarr/auth/env"; -import { auth } from "@homarr/auth/next"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ModalProvider } from "@homarr/modals"; -import { Notifications } from "@homarr/notifications"; -import { SettingsProvider } from "@homarr/settings"; -import { SpotlightProvider } from "@homarr/spotlight"; - -import { Analytics } from "~/components/layout/analytics"; -import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; -import { DayJsLoader } from "./_client-providers/dayjs-loader"; -import { JotaiProvider } from "./_client-providers/jotai"; -import { CustomMantineProvider } from "./_client-providers/mantine"; -import { AuthProvider } from "./_client-providers/session"; -import { TRPCReactProvider } from "./_client-providers/trpc"; -import { composeWrappers } from "./compose"; - -const logger = createLogger({ module: "sessionScopedProviders" }); - -export async function SessionScopedProviders({ children }: PropsWithChildren) { - const session = await auth(); - - const [user, serverSettings] = await Promise.all([ - session - ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { - logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); - return null; - }) - : Promise.resolve(null), - getRscServerSettingsAsync(), - ]); - - const StackedProvider = composeWrappers([ - (innerProps) => , - (innerProps) => ( - - ), - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - (innerProps) => , - ]); - - return ( - - - - - {children} - - ); -} diff --git a/apps/nextjs/src/app/[locale]/auth/login/page.tsx b/apps/nextjs/src/app/[locale]/auth/login/page.tsx index 2ed2f7181..30eb88ba0 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/page.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/page.tsx @@ -1,6 +1,5 @@ -import { Suspense } from "react"; import { redirect } from "next/navigation"; -import { Alert, Card, Center, Code, Loader, Stack, Text, Title } from "@mantine/core"; +import { Alert, Card, Center, Code, Stack, Text, Title } from "@mantine/core"; import { IconLogin } from "@tabler/icons-react"; import { env } from "@homarr/auth/env"; @@ -19,12 +18,12 @@ interface LoginProps { }>; } -async function LoginContent({ searchParams }: { searchParams: Promise<{ callbackUrl?: string }> }) { - const resolvedParams = await searchParams; +export default async function Login(props: LoginProps) { + const searchParams = await props.searchParams; const session = await auth(); if (session) { - redirect(sanitizeRedirectionUrl(resolvedParams.callbackUrl)); + redirect(sanitizeRedirectionUrl(searchParams.callbackUrl)); } const t = await getScopedI18n("user.page.login"); @@ -53,24 +52,10 @@ async function LoginContent({ searchParams }: { searchParams: Promise<{ callback providers={env.AUTH_PROVIDERS} oidcClientName={env.AUTH_OIDC_CLIENT_NAME} isOidcAutoLoginEnabled={env.AUTH_OIDC_AUTO_LOGIN} - callbackUrl={resolvedParams.callbackUrl ?? "/"} + callbackUrl={searchParams.callbackUrl ?? "/"} /> ); } - -export default function Login(props: LoginProps) { - return ( - - - - } - > - - - ); -} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index a26e1f497..2bb868f7d 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -1,7 +1,5 @@ import type { Metadata } from "next"; -import { Suspense } from "react"; import { cache } from "react"; -import { Center, Loader } from "@mantine/core"; import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content @@ -35,52 +33,6 @@ interface Props { getInitialBoardAsync: (params: TParams) => Promise; } -interface BoardContentPageProps { - params: TParams; - getInitialBoard: (params: TParams) => Promise; -} - -async function BoardContentPageDynamic({ - params, - getInitialBoard, -}: BoardContentPageProps) { - const queryClient = getQueryClient(); - - const [board, session] = await Promise.all([getInitialBoard(params), auth()]); - - const itemsMap = board.items.reduce((acc, item) => { - const existing = acc.get(item.kind); - if (existing) { - existing.push(item); - } else { - acc.set(item.kind, [item]); - } - return acc; - }, new Map()); - const [integrations] = await Promise.all([ - getIntegrationsWithPermissionsAsync(session), - ...Array.from(itemsMap).map(([kind, items]) => - prefetchForKindAsync(kind, queryClient, items).catch((error) => { - logger.error( - new ErrorWithMetadata( - "Failed to prefetch widget", - { widgetKind: kind, itemCount: items.length }, - { cause: error }, - ), - ); - }), - ), - ]); - - return ( - - - - - - ); -} - export const createBoardContentPage = >({ getInitialBoardAsync: getInitialBoard, }: Props) => { @@ -93,17 +45,40 @@ export const createBoardContentPage = >( // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { const resolvedParams = await params; + const queryClient = getQueryClient(); + + const [board, session] = await Promise.all([getInitialBoard(resolvedParams), auth()]); + + const itemsMap = board.items.reduce((acc, item) => { + const existing = acc.get(item.kind); + if (existing) { + existing.push(item); + } else { + acc.set(item.kind, [item]); + } + return acc; + }, new Map()); + const [integrations] = await Promise.all([ + getIntegrationsWithPermissionsAsync(session), + ...Array.from(itemsMap).map(([kind, items]) => + prefetchForKindAsync(kind, queryClient, items).catch((error) => { + logger.error( + new ErrorWithMetadata( + "Failed to prefetch widget", + { widgetKind: kind, itemCount: items.length }, + { cause: error }, + ), + ); + }), + ), + ]); return ( - - - - } - > - - + + + + + ); }, generateMetadataAsync: async ({ params }: { params: Promise }): Promise => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx index efca6357c..0ea5c9cf0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_theme.tsx @@ -11,8 +11,8 @@ import { useColorSchemeManager } from "../../_client-providers/mantine"; export const BoardMantineProvider = ({ children, - defaultColorScheme = "auto", -}: PropsWithChildren<{ defaultColorScheme?: ColorScheme }>) => { + defaultColorScheme, +}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => { const board = useRequiredBoard(); const colorSchemeManager = useColorSchemeManager(); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index f3cf7855c..24ebe3d03 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -1,7 +1,6 @@ import type { JSX, PropsWithChildren } from "react"; -import { Suspense } from "react"; import { notFound, redirect } from "next/navigation"; -import { AppShellMain, Skeleton } from "@mantine/core"; +import { AppShellMain } from "@mantine/core"; import { TRPCError } from "@trpc/server"; import { getRscUserSettingsAsync } from "@homarr/api/user-server"; @@ -12,10 +11,10 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { MainHeader } from "~/components/layout/header"; import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; -import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; import { ClientShell } from "~/components/layout/shell"; import { BoardTourGate } from "~/components/onboarding/board-tour-gate"; import { env } from "~/env"; +import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; import type { Board } from "./_types"; import type { Params } from "./(content)/_creator"; import { CustomCss } from "./(content)/_custom-css"; @@ -30,94 +29,6 @@ interface CreateBoardLayoutProps { withTour?: boolean; } -const BoardLayoutFallback = ({ headerActions }: { headerActions: JSX.Element }) => ( - - } actions={headerActions} hasNavigation={false} /> - - - - -); - -interface BoardLayoutDynamicProps { - params: TParams; - headerActions: JSX.Element; - getInitialBoardAsync: (params: TParams) => Promise; - withTour: boolean; - children: React.ReactNode; -} - -async function BoardLayoutDynamic({ - params, - headerActions, - getInitialBoardAsync: getInitialBoard, - withTour, - children, -}: BoardLayoutDynamicProps) { - const sessionPromise = auth(); - const initialBoardPromise = getInitialBoard(params).then( - (board) => ({ status: "fulfilled", board }) as const, - (error: unknown) => ({ status: "rejected", error }) as const, - ); - const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { - if (!withTour || !session || env.DEMO_MODE) return false; - - try { - const user = await getRscUserSettingsAsync(session.user.id); - return user !== undefined && !user.completedBoardTour; - } catch (error) { - logger.error(new Error("Failed to load the board tour status", { cause: error })); - return false; - } - }); - const [session, initialBoardResult, shouldRunBoardTour] = await Promise.all([ - sessionPromise, - initialBoardPromise, - shouldRunBoardTourPromise, - ]); - if (initialBoardResult.status === "rejected") { - const { error } = initialBoardResult; - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - if (!session) { - logger.debug("No home board found for anonymous user, redirecting to login"); - redirect("/auth/login"); - } - - logger.warn(error); - notFound(); - } - - if (error instanceof TRPCError && error.code === "BAD_REQUEST") { - notFound(); - } - - throw error; - } - const initialBoard = initialBoardResult.board; - - return ( - - - - - - - - } - actions={headerActions} - hasNavigation={false} - /> - {children} - - - - - - - ); -} - export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, @@ -130,18 +41,69 @@ export const createBoardLayout = ({ params: Promise; }>) => { const resolvedParams = await params; + const sessionPromise = auth(); + const initialBoardPromise = getInitialBoard(resolvedParams).then( + (board) => ({ status: "fulfilled", board }) as const, + (error: unknown) => ({ status: "rejected", error }) as const, + ); + const colorSchemePromise = getCurrentColorSchemeAsync(); + const shouldRunBoardTourPromise = sessionPromise.then(async (session) => { + if (!withTour || !session || env.DEMO_MODE) return false; + + try { + const user = await getRscUserSettingsAsync(session.user.id); + return user !== undefined && !user.completedBoardTour; + } catch (error) { + logger.error(new Error("Failed to load the board tour status", { cause: error })); + return false; + } + }); + const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([ + sessionPromise, + initialBoardPromise, + colorSchemePromise, + shouldRunBoardTourPromise, + ]); + if (initialBoardResult.status === "rejected") { + const { error } = initialBoardResult; + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + if (!session) { + logger.debug("No home board found for anonymous user, redirecting to login"); + redirect("/auth/login"); + } + + logger.warn(error); + notFound(); + } + + if (error instanceof TRPCError && error.code === "BAD_REQUEST") { + notFound(); + } + + throw error; + } + const initialBoard = initialBoardResult.board; return ( - }> - - {children} - - + + + + + + + + } + actions={headerActions} + hasNavigation={false} + /> + {children} + + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 384eb3358..ea62beadf 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -1,7 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Suspense } from "react"; import { Inter } from "next/font/google"; -import { Center, ColorSchemeScript, Loader } from "@mantine/core"; import "@gfazioli/mantine-onboarding-tour/styles.css"; import "@homarr/notifications/styles.css"; @@ -12,19 +10,42 @@ import "~/styles/color-scheme.scss"; import "~/styles/scroll-area.scss"; import { notFound } from "next/navigation"; +import type { DayOfWeek } from "@mantine/dates"; +import { NextIntlClientProvider } from "next-intl"; +import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server"; +import { getRscUserSettingsAsync } from "@homarr/api/user-server"; +import { env } from "@homarr/auth/env"; +import { auth } from "@homarr/auth/next"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ModalProvider } from "@homarr/modals"; +import { Notifications } from "@homarr/notifications"; +import { SettingsProvider } from "@homarr/settings"; +import { SpotlightProvider } from "@homarr/spotlight"; import type { SupportedLanguage } from "@homarr/translation"; import { isLocaleRTL, isLocaleSupported } from "@homarr/translation"; -import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; +import { Analytics } from "~/components/layout/analytics"; import { CrowdinLiveTranslation } from "~/components/layout/crowdin-live-translation"; -import { SessionScopedProviders } from "./_session-scoped-providers"; + +import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization"; +import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration"; +import { getCurrentColorSchemeAsync } from "~/theme/color-scheme"; +import { DayJsLoader } from "./_client-providers/dayjs-loader"; +import { JotaiProvider } from "./_client-providers/jotai"; +import { CustomMantineProvider } from "./_client-providers/mantine"; +import { AuthProvider } from "./_client-providers/session"; +import { TRPCReactProvider } from "./_client-providers/trpc"; +import { composeWrappers } from "./compose"; const fontSans = Inter({ subsets: ["latin"], variable: "--font-sans", }); +const logger = createLogger({ module: "rootLayout" }); + +// eslint-disable-next-line no-restricted-syntax export const generateMetadata = async (): Promise => ({ title: "Homarr", description: @@ -44,7 +65,7 @@ export const generateMetadata = async (): Promise => ({ title: "Homarr", capable: true, startupImage: { url: "/logo/logo.png" }, - statusBarStyle: "default", + statusBarStyle: (await getCurrentColorSchemeAsync()) === "dark" ? "black-translucent" : "default", }, }); @@ -64,27 +85,82 @@ export default async function Layout(props: { notFound(); } + const sessionPromise = auth(); + const userPromise = sessionPromise.then((session) => + session + ? getRscUserSettingsAsync(session.user.id).catch((error: unknown) => { + logger.error(new Error("Failed to load the authenticated user in the root layout", { cause: error })); + return null; + }) + : null, + ); + const [session, user, serverSettings, colorScheme] = await Promise.all([ + sessionPromise, + userPromise, + getRscServerSettingsAsync(), + getCurrentColorSchemeAsync(), + ]); const direction = isLocaleRTL(locale) ? "rtl" : "ltr"; + const StackedProvider = composeWrappers([ + (innerProps) => { + return ; + }, + (innerProps) => ( + + ), + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + (innerProps) => , + ]); + return ( - + // Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering + - - - - + - - - - } - > - {props.children} - + + + + + {props.children} + ); diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 927051d13..33bdcce06 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren } from "react"; -import { Suspense } from "react"; -import { AppShellMain, Skeleton } from "@mantine/core"; +import { AppShellMain } from "@mantine/core"; import { IconAffiliateFilled, IconApi, @@ -47,17 +46,7 @@ import { env as nextEnv } from "~/env"; const logger = createLogger({ module: "manageLayout" }); -const ManageLayoutFallback = () => ( - - - - - - - -); - -async function ManageLayoutContent({ children }: PropsWithChildren) { +export default async function ManageLayout({ children }: PropsWithChildren) { const sessionPromise = auth(); const shouldRunManageTourPromise = sessionPromise.then(async (session) => { if (!session || nextEnv.DEMO_MODE) return false; @@ -248,21 +237,17 @@ async function ManageLayoutContent({ children }: PropsWithChildren) { const isAdmin = session?.user.permissions.includes("admin") ?? false; + const shell = ( + + + + {children} + + ); + return ( - - - - {children} - + {shell} ); } - -export default function ManageLayout({ children }: PropsWithChildren) { - return ( - }> - {children} - - ); -} diff --git a/packages/redis/src/lib/connection.ts b/packages/redis/src/lib/connection.ts index 74bc3bd73..7c2bd473a 100644 --- a/packages/redis/src/lib/connection.ts +++ b/packages/redis/src/lib/connection.ts @@ -4,11 +4,19 @@ import { createRedisClient } from "@homarr/core/infrastructure/redis"; let dataClient: RedisClient | undefined; let subscriberClient: RedisClient | undefined; -const isRedisExplicitlyDisabled = () => process.env.CI === "true" || process.env.CI === "1" || process.env.DISABLE_REDIS_LOGS === "true" || process.env.DISABLE_REDIS_LOGS === "1"; +const isRedisExplicitlyDisabled = () => + process.env.CI === "true" || + process.env.CI === "1" || + process.env.DISABLE_REDIS_LOGS === "true" || + process.env.DISABLE_REDIS_LOGS === "1"; /** - * In-process pub/sub fallback for single-instance deployments when Redis is unavailable. - * Never used when REDIS_IS_EXTERNAL=true (multi-instance requires real Redis). + * In-process pub/sub fallback for single-instance deployments, opted into via + * CI/DISABLE_REDIS_LOGS. This is a static, env-driven switch — it does not detect + * Redis unreachability at runtime; an unreachable Redis outside those flags still + * attempts a real connection (with ioredis's default retry/backoff), it does not + * fall back automatically. Never used when REDIS_IS_EXTERNAL=true (multi-instance + * requires real Redis). */ export const usesMemoryFallback = (): boolean => process.env.REDIS_IS_EXTERNAL !== "true" && isRedisExplicitlyDisabled();