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:
Thomas Camlong committed 2026-08-08 12:40:48 +02:00
1 parent 3db87d781f
commit b1713a9820
10 files changed
+221 -321

No files matched your search

-10
View File
@@ -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>
);
};
+95 -19
View File
@@ -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>
);
+11 -26
View File
@@ -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>
);
}
+11 -3
View File
@@ -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();