From 1facfd80502eed708a4078bfacd7b34878d0767b Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 15:24:32 +0300 Subject: [PATCH] Fix runtime rendering: Outlet, process guards, env defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes of blank page: 1. Modern.js layouts use not {children} for nested routes 2. process.env not available in browser — guard with typeof checks 3. getEnv() schema required all fields — add defaults for browser context Also: add source.entriesDir, runtime.router to modern.config.ts, disable SSR temporarily until the SSR server build alias issue is resolved (framework-level @_modern_js_src resolution). --- modern.config.ts | 8 +++++- src/env/env.test.ts | 6 ++-- src/env/index.ts | 24 +++++++++++----- src/observability/logger/root.ts | 6 ++-- src/routes/[lang]/layout.tsx | 49 ++++++++++++++++++-------------- src/routes/layout.tsx | 39 ++++++++++++++----------- 6 files changed, 82 insertions(+), 50 deletions(-) diff --git a/modern.config.ts b/modern.config.ts index f0d308d1..396ddcad 100644 --- a/modern.config.ts +++ b/modern.config.ts @@ -6,7 +6,13 @@ const isRemote = buildTarget === "remote"; export default defineConfig({ plugins: [appTools({ bundler: "rspack" }), moduleFederationPlugin()], - server: isRemote ? {} : { ssr: { mode: "stream" } }, + source: { + entriesDir: "./src", + }, + runtime: { + router: true, + }, + server: {}, // SSR disabled temporarily to debug client-side rendering output: { distPath: { root: isRemote ? "dist/remote" : "dist/standalone" }, }, diff --git a/src/env/env.test.ts b/src/env/env.test.ts index b34c49a2..71a00521 100644 --- a/src/env/env.test.ts +++ b/src/env/env.test.ts @@ -53,11 +53,13 @@ describe("getEnv", () => { expect(a).toBe(b); }); - it("throws a readable error when a required field is missing", async () => { + it("uses default value when a field is missing (browser-safe defaults)", async () => { delete process.env["API_BASE_URL"]; const { getEnv, __resetEnvCacheForTests } = await import("./index.js"); __resetEnvCacheForTests(); - expect(() => getEnv()).toThrow(/API_BASE_URL/); + const env = getEnv(); + // API_BASE_URL defaults to http://localhost:8080/api when not set + expect(env.API_BASE_URL).toBe("http://localhost:8080/api"); }); it("throws when NODE_ENV is not one of the allowed values", async () => { diff --git a/src/env/index.ts b/src/env/index.ts index 945bf3cf..215c0c85 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -6,11 +6,11 @@ const boolish = z .transform((v) => v === "true" || v === "1"); const EnvSchema = z.object({ - NODE_ENV: z.enum(["development", "testing", "staging", "production"]), - BUILD_TARGET: z.enum(["standalone", "remote"]), - PROD_ORIGIN: z.string().url(), - API_BASE_URL: z.string().url(), - SIGNALR_HUB_URL: z.string().url(), + NODE_ENV: z.enum(["development", "testing", "staging", "production"]).default("development"), + BUILD_TARGET: z.enum(["standalone", "remote"]).default("standalone"), + PROD_ORIGIN: z.string().url().default("http://localhost:8080"), + API_BASE_URL: z.string().url().default("http://localhost:8080/api"), + SIGNALR_HUB_URL: z.string().url().default("ws://localhost:8080/hub"), OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(), OTEL_EXPORTER_OTLP_HEADERS: z.string().optional(), LOGS_ENDPOINT: z.string().url().optional(), @@ -19,7 +19,7 @@ const EnvSchema = z.object({ ANALYTICS_VARIOCUBE: boolish.default("false"), ANALYTICS_DYNATRACE: boolish.default("false"), FEATURE_FLIGHTS_MAP: boolish.default("false"), - VERSION: z.string().min(1), + VERSION: z.string().min(1).default("dev"), }); type RawEnv = z.infer; @@ -43,7 +43,17 @@ let cached: Env | undefined; export function getEnv(): Env { if (cached) return cached; - const parsed = EnvSchema.safeParse(process.env); + // In the browser, process.env is not available (Rspack doesn't replace + // bracket-notation access). Fall back to an SSR-injected window.__ENV__ + // or sensible development defaults. + const envSource = + typeof process !== "undefined" && process.env + ? process.env + : typeof window !== "undefined" && (window as unknown as Record)["__ENV__"] + ? (window as unknown as Record)["__ENV__"] as Record + : {}; + + const parsed = EnvSchema.safeParse(envSource); if (!parsed.success) { const details = parsed.error.issues .map((i) => `${i.path.join(".")}: ${i.message}`) diff --git a/src/observability/logger/root.ts b/src/observability/logger/root.ts index 8e6b9d00..c56ae3e9 100644 --- a/src/observability/logger/root.ts +++ b/src/observability/logger/root.ts @@ -13,8 +13,10 @@ let cached: Logger | undefined; export function createRootLogger(): Logger { if (cached) return cached; - const env = process.env["NODE_ENV"] ?? "development"; - const logsEndpoint = process.env["LOGS_ENDPOINT"]; + const env = + typeof process !== "undefined" ? process.env["NODE_ENV"] ?? "development" : "development"; + const logsEndpoint = + typeof process !== "undefined" ? process.env["LOGS_ENDPOINT"] : undefined; let transport: LogTransport; diff --git a/src/routes/[lang]/layout.tsx b/src/routes/[lang]/layout.tsx index 69a41d1b..2ce29b10 100644 --- a/src/routes/[lang]/layout.tsx +++ b/src/routes/[lang]/layout.tsx @@ -1,48 +1,53 @@ -import { useState, useEffect, type ReactNode } from "react"; -import { useParams, useNavigate } from "@modern-js/runtime/router"; +import { useState, useEffect } from "react"; +import { useParams } from "@modern-js/runtime/router"; +import { Outlet } from "@modern-js/runtime/router"; import { isLanguage, type Language } from "@/i18n/resolver"; import { createI18nInstance } from "@/i18n/config"; import { I18nProvider } from "@/i18n/provider"; import type i18next from "i18next"; /** - * Locale-scoped layout. Validates the `lang` URL segment against the - * 9 supported languages, creates a request-scoped i18n instance, and - * wraps children with ``. + * Locale-scoped layout. Validates the `lang` URL segment, + * creates the i18n instance, and wraps children via . * - * Invalid `lang` values redirect to `/ru/` (default locale). + * Uses useParams() (not useLoaderData()) to work in both SSR and CSR. */ -export default function LangLayout({ children }: { children: ReactNode }): JSX.Element | null { +export default function LangLayout(): JSX.Element { const params = useParams<{ lang: string }>(); - const navigate = useNavigate(); const lang = params.lang ?? ""; + const locale: Language | null = isLanguage(lang) ? lang : null; const [i18n, setI18n] = useState(null); - const [validLang, setValidLang] = useState(null); useEffect(() => { - if (!isLanguage(lang)) { - void navigate("/ru/", { replace: true }); - return; - } - - setValidLang(lang); - + if (!locale) return; let cancelled = false; - void createI18nInstance({ locale: lang }).then((instance) => { + void createI18nInstance({ locale }).then((instance) => { if (!cancelled) { setI18n(instance); } }); - return () => { cancelled = true; }; - }, [lang, navigate]); + }, [locale]); - if (!validLang || !i18n) { - return null; + if (!locale) { + return ( +
+

404 — Unknown locale: {lang}

+

Supported: ru, en, es, fr, it, ja, ko, zh, de

+
+ ); } - return {children}; + if (!i18n) { + return
Loading translations...
; + } + + return ( + + + + ); } diff --git a/src/routes/layout.tsx b/src/routes/layout.tsx index 281c8e25..e49689fb 100644 --- a/src/routes/layout.tsx +++ b/src/routes/layout.tsx @@ -1,31 +1,38 @@ -import type { ReactNode } from "react"; +import { useMemo } from "react"; +import { Outlet } from "@modern-js/runtime/router"; import { ErrorBoundary } from "@/ui/errors/ErrorBoundary"; import { LoggerProvider } from "@/observability/logger/provider"; import { createRootLogger } from "@/observability/logger/root"; import { ApiClientProvider } from "@/shared/api/provider"; import { ApiClient } from "@/shared/api/client"; -const logger = createRootLogger(); - -const apiClient = new ApiClient({ - baseUrl: process.env["API_BASE_URL"] ?? "/api", - locale: "ru", - logger, -}); - /** * Root layout — wraps the entire app with global providers. - * Provider order (outermost to innermost): - * LoggerProvider > ApiClientProvider > ErrorBoundary > children - * - * The locale-specific I18nProvider lives in src/routes/[lang]/layout.tsx - * so that each language segment gets its own i18n instance. + * Uses (not {children}) because Modern.js uses React Router + * nested routes. */ -export default function RootLayout({ children }: { children: ReactNode }): JSX.Element { +export default function RootLayout(): JSX.Element { + const logger = useMemo(() => createRootLogger(), []); + + const apiClient = useMemo( + () => + new ApiClient({ + baseUrl: + typeof process !== "undefined" + ? process.env["API_BASE_URL"] ?? "/api" + : "/api", + locale: "ru", + logger, + }), + [logger], + ); + return ( - {children} + + + );