Fix runtime rendering: Outlet, process guards, env defaults
Deploy / build-and-deploy (push) Failing after 5s

Three root causes of blank page:
1. Modern.js layouts use <Outlet /> 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).
This commit is contained in:
2026-04-15 15:24:32 +03:00
parent b4266e4b0f
commit 1facfd8050
6 changed files with 82 additions and 50 deletions
+7 -1
View File
@@ -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" },
},
+4 -2
View File
@@ -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 () => {
+17 -7
View File
@@ -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<typeof EnvSchema>;
@@ -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<string, unknown>)["__ENV__"]
? (window as unknown as Record<string, unknown>)["__ENV__"] as Record<string, string>
: {};
const parsed = EnvSchema.safeParse(envSource);
if (!parsed.success) {
const details = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
+4 -2
View File
@@ -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;
+27 -22
View File
@@ -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 `<I18nProvider>`.
* Locale-scoped layout. Validates the `lang` URL segment,
* creates the i18n instance, and wraps children via <Outlet />.
*
* 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<typeof i18next | null>(null);
const [validLang, setValidLang] = useState<Language | null>(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 (
<div>
<h2>404 Unknown locale: {lang}</h2>
<p>Supported: ru, en, es, fr, it, ja, ko, zh, de</p>
</div>
);
}
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
if (!i18n) {
return <div>Loading translations...</div>;
}
return (
<I18nProvider i18n={i18n}>
<Outlet />
</I18nProvider>
);
}
+23 -16
View File
@@ -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 <Outlet /> (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 (
<LoggerProvider logger={logger}>
<ApiClientProvider client={apiClient}>
<ErrorBoundary>{children}</ErrorBoundary>
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</ApiClientProvider>
</LoggerProvider>
);