Fix runtime rendering: Outlet, process guards, env defaults
Deploy / build-and-deploy (push) Failing after 5s
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:
+7
-1
@@ -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" },
|
||||
},
|
||||
|
||||
Vendored
+4
-2
@@ -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 () => {
|
||||
|
||||
Vendored
+17
-7
@@ -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}`)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user