fix: revert Suspense layout wrappers causing E2E hydration errors
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.
This commit is contained in:
10 files changed
+221
-321
No files matched your search
@@ -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 */
|
||||
|
||||
@@ -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 (
|
||||
<DirectionProvider>
|
||||
|
||||
@@ -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) => <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />,
|
||||
(innerProps) => (
|
||||
<SettingsProvider
|
||||
user={
|
||||
user
|
||||
? {
|
||||
...user,
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
}
|
||||
: null
|
||||
}
|
||||
serverSettings={{
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
),
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
<StackedProvider>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
</StackedProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "/"}
|
||||
/>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login(props: LoginProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="100vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<LoginContent searchParams={props.searchParams} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<TParams extends Params> {
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
}
|
||||
|
||||
interface BoardContentPageProps<TParams extends Params> {
|
||||
params: TParams;
|
||||
getInitialBoard: (params: TParams) => Promise<Board>;
|
||||
}
|
||||
|
||||
async function BoardContentPageDynamic<TParams extends Params>({
|
||||
params,
|
||||
getInitialBoard,
|
||||
}: BoardContentPageProps<TParams>) {
|
||||
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<WidgetKind, Item[]>());
|
||||
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 (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export const createBoardContentPage = <TParams extends Record<string, unknown>>({
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
}: Props<TParams>) => {
|
||||
@@ -93,17 +45,40 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async ({ params }: { params: Promise<TParams> }) => {
|
||||
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<WidgetKind, Item[]>());
|
||||
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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center w="100%" h="100%">
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<BoardContentPageDynamic params={resolvedParams} getInitialBoard={getInitialBoard} />
|
||||
</Suspense>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<DynamicClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<TParams extends Params> {
|
||||
withTour?: boolean;
|
||||
}
|
||||
|
||||
const BoardLayoutFallback = ({ headerActions }: { headerActions: JSX.Element }) => (
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader logo={<HomarrLogoWithTitle size="md" />} actions={headerActions} hasNavigation={false} />
|
||||
<AppShellMain>
|
||||
<Skeleton height="100%" radius="sm" />
|
||||
</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
interface BoardLayoutDynamicProps<TParams extends Params> {
|
||||
params: TParams;
|
||||
headerActions: JSX.Element;
|
||||
getInitialBoardAsync: (params: TParams) => Promise<Board>;
|
||||
withTour: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
async function BoardLayoutDynamic<TParams extends Params>({
|
||||
params,
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour,
|
||||
children,
|
||||
}: BoardLayoutDynamicProps<TParams>) {
|
||||
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 (
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const createBoardLayout = <TParams extends Params>({
|
||||
headerActions,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
@@ -130,18 +41,69 @@ export const createBoardLayout = <TParams extends Params>({
|
||||
params: Promise<TParams>;
|
||||
}>) => {
|
||||
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 (
|
||||
<Suspense fallback={<BoardLayoutFallback headerActions={headerActions} />}>
|
||||
<BoardLayoutDynamic
|
||||
params={resolvedParams}
|
||||
headerActions={headerActions}
|
||||
getInitialBoardAsync={getInitialBoard}
|
||||
withTour={withTour}
|
||||
>
|
||||
{children}
|
||||
</BoardLayoutDynamic>
|
||||
</Suspense>
|
||||
<BoardProvider initialBoard={initialBoard}>
|
||||
<BoardReadyProvider>
|
||||
<EditModeProvider>
|
||||
<BoardMantineProvider defaultColorScheme={colorScheme}>
|
||||
<CustomCss />
|
||||
<BoardTourGate enabled={shouldRunBoardTour}>
|
||||
<ClientShell hasNavigation={false}>
|
||||
<MainHeader
|
||||
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
|
||||
actions={headerActions}
|
||||
hasNavigation={false}
|
||||
/>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
</BoardTourGate>
|
||||
</BoardMantineProvider>
|
||||
</EditModeProvider>
|
||||
</BoardReadyProvider>
|
||||
</BoardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Metadata> => ({
|
||||
title: "Homarr",
|
||||
description:
|
||||
@@ -44,7 +65,7 @@ export const generateMetadata = async (): Promise<Metadata> => ({
|
||||
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 <AuthProvider session={session} logoutUrl={env.AUTH_LOGOUT_REDIRECT_URL} {...innerProps} />;
|
||||
},
|
||||
(innerProps) => (
|
||||
<SettingsProvider
|
||||
user={
|
||||
user
|
||||
? {
|
||||
...user,
|
||||
// Convert type, because output schema is not smart enough to infer $type from drizzle
|
||||
firstDayOfWeek: user.firstDayOfWeek as DayOfWeek,
|
||||
}
|
||||
: null
|
||||
}
|
||||
serverSettings={{
|
||||
board: {
|
||||
homeBoardId: serverSettings.board.homeBoardId,
|
||||
mobileHomeBoardId: serverSettings.board.mobileHomeBoardId,
|
||||
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
|
||||
forceDisableStatus: serverSettings.board.forceDisableStatus,
|
||||
},
|
||||
search: { defaultSearchEngineId: serverSettings.search.defaultSearchEngineId },
|
||||
user: { enableGravatar: serverSettings.user.enableGravatar },
|
||||
}}
|
||||
{...innerProps}
|
||||
/>
|
||||
),
|
||||
(innerProps) => <JotaiProvider {...innerProps} />,
|
||||
(innerProps) => <TRPCReactProvider {...innerProps} />,
|
||||
(innerProps) => <DayJsLoader {...innerProps} />,
|
||||
(innerProps) => <NextIntlClientProvider {...innerProps} />,
|
||||
(innerProps) => <CustomMantineProvider {...innerProps} defaultColorScheme={colorScheme} />,
|
||||
(innerProps) => <ModalProvider {...innerProps} />,
|
||||
(innerProps) => <SpotlightProvider {...innerProps} />,
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={direction} suppressHydrationWarning>
|
||||
// Instead of ColorSchemScript we use data-mantine-color-scheme to prevent flickering
|
||||
<html
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
data-mantine-color-scheme={colorScheme}
|
||||
style={{
|
||||
backgroundColor: colorScheme === "dark" ? "#242424" : colorScheme === "auto" ? undefined : "#fff",
|
||||
}}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<ColorSchemeScript />
|
||||
<Suspense>
|
||||
<SearchEngineOptimization />
|
||||
</Suspense>
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="100vh" w="100%">
|
||||
<Loader size="xl" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<SessionScopedProviders>{props.children}</SessionScopedProviders>
|
||||
</Suspense>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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 = () => (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader />
|
||||
<MainNavigation links={[]} />
|
||||
<AppShellMain>
|
||||
<Skeleton height="100%" radius="sm" />
|
||||
</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader></MainHeader>
|
||||
<MainNavigation links={navigationLinks}></MainNavigation>
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
);
|
||||
|
||||
return (
|
||||
<ManageTourGate enabled={shouldRunManageTour} isAdmin={isAdmin}>
|
||||
<ClientShell hasNavigation>
|
||||
<MainHeader />
|
||||
<MainNavigation links={navigationLinks} />
|
||||
<AppShellMain>{children}</AppShellMain>
|
||||
</ClientShell>
|
||||
{shell}
|
||||
</ManageTourGate>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManageLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Suspense fallback={<ManageLayoutFallback />}>
|
||||
<ManageLayoutContent>{children}</ManageLayoutContent>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user