mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-20 22:02:19 +03:00
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:
1
changelog.d/fixes/12995-combos-i18n-formatting-error.md
Normal file
1
changelog.d/fixes/12995-combos-i18n-formatting-error.md
Normal 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)).
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
94
tests/unit/BuilderIntelligentStep-formatting-error.test.tsx
Normal file
94
tests/unit/BuilderIntelligentStep-formatting-error.test.tsx
Normal 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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user