fix(i18n): stop swallowed FORMATTING_ERROR from showing raw keys/garbled text (#12995)

Merged. A swallowed `FORMATTING_ERROR` surfacing as raw keys or garbled text is exactly the failure mode i18n is supposed to prevent; the user sees the plumbing. Good catch.

Validated as a combined board first (this PR merged with the 21 siblings of the same wave on the release tip): eslint with the frozen suppressions, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-counts, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 176 passing / 0 failing focused node:test cases across the 25 test files the wave touches and the dashboard test under Vitest (2/0). Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run.

Thank you.
This commit is contained in:
Markus Hartung
2026-09-16 18:37:07 +02:00
committed by GitHub
parent 7dbe850daa
commit bc36b1d1aa
5 changed files with 149 additions and 22 deletions

View File

@@ -0,0 +1 @@
- **fix(i18n):** stop the "Saving..." hang on `/dashboard/combos``BuilderIntelligentStep.tsx`'s exploration-rate hint called `t("explorationRateHint")` with no ICU values even though the key requires `{percent}`, and next-intl's default error handling throws `FORMATTING_ERROR` for that call, unmounting the whole builder step and looking like a silent save hang. Fixed across all three affected call sites (`BuilderIntelligentStep.tsx`, `AgentBridgeMaintenanceCard.tsx`, `RawJsonPanel.tsx`), not just the one that was reported ([#12995](https://github.com/diegosouzapw/OmniRoute/pull/12995)).

View File

@@ -12,8 +12,14 @@ import {
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { compareTr } from "@/shared/utils/turkishText";
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
function getI18nOrFallback(t: any, key: string, fallback: string, values?: Record<string, unknown>) {
try {
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
} catch {
// A registered message can require an ICU variable (e.g. {percent}) that
// this call site doesn't know about yet -- fall back rather than crash
// the whole builder step's render.
}
return fallback;
}
@@ -328,11 +334,15 @@ export default function BuilderIntelligentStep({
className="mt-3 w-full accent-primary"
/>
<p className="text-[11px] text-text-muted mt-2">
{getI18nOrFallback(
t,
"explorationRateHint",
"{percent}% of requests can explore non-optimal providers."
).replace("{percent}", `${Math.round(normalizedConfig.explorationRate * 100)}`)}
{(() => {
const percent = Math.round(normalizedConfig.explorationRate * 100);
return getI18nOrFallback(
t,
"explorationRateHint",
"{percent}% of requests can explore non-optimal providers.",
{ percent }
).replace("{percent}", `${percent}`);
})()}
</p>
</Card.Section>

View File

@@ -90,10 +90,14 @@ export function AgentBridgeMaintenanceCard({
const handleRepair = async (password = "") => {
const { repaired } = await repairMitmState(password || undefined);
const repairedItems = repaired.join(", ");
setNotice(
repaired.length === 0
? t("repairNothing") || "Nothing to repair — system state is clean."
: (t("repairDone") || "Repaired: {items}").replace("{items}", repaired.join(", "))
: (t("repairDone", { items: repairedItems }) || "Repaired: {items}").replace(
"{items}",
repairedItems
)
);
await onRefresh();
};
@@ -171,11 +175,19 @@ export function AgentBridgeMaintenanceCard({
throw new Error(t("importInvalidJson") || "The selected file is not valid JSON.");
}
const result: ImportResult = await importAgentBridgeConfig(parsed as AgentBridgeConfig);
const importValues = {
bypass: String(result.bypassPatterns),
hosts: String(result.customHosts),
agents: String(result.agents),
};
setNotice(
(t("importDone") || "Imported {bypass} bypass · {hosts} hosts · {agents} agents")
.replace("{bypass}", String(result.bypassPatterns))
.replace("{hosts}", String(result.customHosts))
.replace("{agents}", String(result.agents))
(
t("importDone", importValues) ||
"Imported {bypass} bypass · {hosts} hosts · {agents} agents"
)
.replace("{bypass}", importValues.bypass)
.replace("{hosts}", importValues.hosts)
.replace("{agents}", importValues.agents)
);
await onRefresh();
});

View File

@@ -227,9 +227,9 @@ export default function RawJsonPanel({
const tgtMeta = FORMAT_META[targetFormat] ?? FORMAT_META["openai"];
// ── i18n safe getter ───────────────────────────────────────────────────────
const tr = (key: string, fallback: string): string => {
const tr = (key: string, fallback: string, values?: Record<string, string>): string => {
try {
const v = t(key as Parameters<typeof t>[0]);
const v = t(key as Parameters<typeof t>[0], values as never);
if (v === key || v === `translator.${key}`) return fallback;
return v as string;
} catch {
@@ -371,17 +371,27 @@ export default function RawJsonPanel({
</span>
{translationPath === "hub-and-spoke" ? (
<span>
{tr("translationPathHubSpoke", "")
.replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat)
.replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
`${FORMAT_META[sourceFormat]?.label ?? sourceFormat} → OpenAI → ${FORMAT_META[targetFormat]?.label ?? targetFormat}`}
{(() => {
const source = FORMAT_META[sourceFormat]?.label ?? sourceFormat;
const target = FORMAT_META[targetFormat]?.label ?? targetFormat;
return (
tr("translationPathHubSpoke", "", { source, target })
.replace("{source}", source)
.replace("{target}", target) || `${source} → OpenAI → ${target}`
);
})()}
</span>
) : translationPath === "direct" ? (
<span>
{tr("translationPathDirect", "")
.replace("{source}", FORMAT_META[sourceFormat]?.label ?? sourceFormat)
.replace("{target}", FORMAT_META[targetFormat]?.label ?? targetFormat) ||
`${FORMAT_META[sourceFormat]?.label ?? sourceFormat}${FORMAT_META[targetFormat]?.label ?? targetFormat}`}
{(() => {
const source = FORMAT_META[sourceFormat]?.label ?? sourceFormat;
const target = FORMAT_META[targetFormat]?.label ?? targetFormat;
return (
tr("translationPathDirect", "", { source, target })
.replace("{source}", source)
.replace("{target}", target) || `${source}${target}`
);
})()}
</span>
) : (
<span>{tr("translationPathPassthrough", "Passthrough (same format)")}</span>

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
//
// Regression test for the "Saving..." hang on /dashboard/combos: the
// exploration-rate hint called t("explorationRateHint") with no ICU values,
// even though combos.explorationRateHint requires {percent}. next-intl's
// default (no onError override, matching this app's real providers) throws
// FORMATTING_ERROR for that call, which unmounted the whole builder step and
// looked like a silent save hang. See src/i18n/messages/en.json's
// "explorationRateHint" key and BuilderIntelligentStep.tsx's
// getI18nOrFallback().
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTranslator } from "use-intl/core";
import BuilderIntelligentStep from "../../src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep";
import enMessages from "../../src/i18n/messages/en.json";
// A real translator built from the app's actual EN catalog, with no custom
// onError -- this is what NextIntlClientProvider uses by default (see
// app/layout.tsx / app/global-error.tsx), so it faithfully reproduces the
// live FORMATTING_ERROR throw for a message with an unsupplied ICU variable.
const t = createTranslator({
locale: "en",
messages: enMessages as unknown as Record<string, unknown>,
namespace: "combos",
});
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
describe("BuilderIntelligentStep", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
});
it("renders the exploration-rate hint without crashing against the real EN catalog", async () => {
const container = makeContainer();
const root = createRoot(container);
// Pre-fix, this threw FORMATTING_ERROR from inside the render and the
// wizard step never committed to the DOM -- reproducing the reported
// "Saving..." hang with no network activity.
await act(async () => {
root.render(
<BuilderIntelligentStep
t={t}
config={{ explorationRate: 0.2 }}
activeProviders={[]}
onChange={() => {}}
/>
);
});
const text = container.textContent ?? "";
expect(text).toContain("20% of requests can explore non-optimal providers.");
});
it("still renders correctly when explorationRate is 0", async () => {
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<BuilderIntelligentStep
t={t}
config={{ explorationRate: 0 }}
activeProviders={[]}
onChange={() => {}}
/>
);
});
const text = container.textContent ?? "";
expect(text).toContain("0% of requests can explore non-optimal providers.");
});
});