From bc36b1d1aa841867f43d8cdd8f24647a3e71a8d4 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Wed, 16 Sep 2026 18:37:07 +0200 Subject: [PATCH] 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. --- .../12995-combos-i18n-formatting-error.md | 1 + .../combos/BuilderIntelligentStep.tsx | 24 +++-- .../components/AgentBridgeMaintenanceCard.tsx | 22 ++++- .../components/advanced/RawJsonPanel.tsx | 30 ++++-- ...rIntelligentStep-formatting-error.test.tsx | 94 +++++++++++++++++++ 5 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fixes/12995-combos-i18n-formatting-error.md create mode 100644 tests/unit/BuilderIntelligentStep-formatting-error.test.tsx diff --git a/changelog.d/fixes/12995-combos-i18n-formatting-error.md b/changelog.d/fixes/12995-combos-i18n-formatting-error.md new file mode 100644 index 0000000000..e1c02968e7 --- /dev/null +++ b/changelog.d/fixes/12995-combos-i18n-formatting-error.md @@ -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)). diff --git a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx index 0d5e87a52a..2ec42af3f0 100644 --- a/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx +++ b/src/app/(dashboard)/dashboard/combos/BuilderIntelligentStep.tsx @@ -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) { + 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" />

- {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}`); + })()}

diff --git a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeMaintenanceCard.tsx b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeMaintenanceCard.tsx index f06ff82162..80ffc6ae31 100644 --- a/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeMaintenanceCard.tsx +++ b/src/app/(dashboard)/dashboard/tools/agent-bridge/components/AgentBridgeMaintenanceCard.tsx @@ -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(); }); diff --git a/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx index 839e77a229..95e7b35d76 100644 --- a/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/advanced/RawJsonPanel.tsx @@ -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 => { try { - const v = t(key as Parameters[0]); + const v = t(key as Parameters[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({ {translationPath === "hub-and-spoke" ? ( - {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}` + ); + })()} ) : translationPath === "direct" ? ( - {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}` + ); + })()} ) : ( {tr("translationPathPassthrough", "Passthrough (same format)")} diff --git a/tests/unit/BuilderIntelligentStep-formatting-error.test.tsx b/tests/unit/BuilderIntelligentStep-formatting-error.test.tsx new file mode 100644 index 0000000000..674c491de4 --- /dev/null +++ b/tests/unit/BuilderIntelligentStep-formatting-error.test.tsx @@ -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, + 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( + {}} + /> + ); + }); + + 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( + {}} + /> + ); + }); + + const text = container.textContent ?? ""; + expect(text).toContain("0% of requests can explore non-optimal providers."); + }); +});