Skip to content

Commit 23d65c3

Browse files
authored
fix(i18n): drop try/catch-around-hook in createSafeTranslation / useSafeTranslate (#2605)
Last known rules-of-hooks violation of the #2595/#2596 class — the factory closure escaped static lint but carried the same hook-order desync risk. useObjectTranslation is provider-safe; the real fallbacks (testKey probe / per-key detection) are unchanged and now test-locked, and the fallback t is a stable per-factory reference so downstream memo deps stop invalidating in the no-translations case. vitest i18n+plugin-detail+fields 656 ✓.
1 parent 770e405 commit 23d65c3

3 files changed

Lines changed: 84 additions & 36 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
fix(i18n): `createSafeTranslation` / `useSafeTranslate` no longer wrap the
6+
translation hook in try/catch — the last known rules-of-hooks violation of
7+
the class fixed in objectui#2595/#2596 (a throw after the hook ran would
8+
desync hook order on the next render; the factory closure just escaped the
9+
static lint). `useObjectTranslation` is provider-safe, and the actual
10+
fallback behavior is unchanged: the testKey probe (createSafeTranslation)
11+
and per-key `t(key) === key` detection (useSafeTranslate) still return the
12+
English defaults when translations aren't configured. The fallback `t` is
13+
now a stable per-factory reference, so downstream memo deps stop
14+
invalidating every render in the no-translations case.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Locks the fallback semantics of the safe-translation hooks AFTER the
3+
* try/catch-around-hook removal (rules-of-hooks, objectui#2595/#2596 class):
4+
* with no I18nProvider mounted, consumers must still get English defaults —
5+
* via the testKey probe / per-key detection, not via a caught throw.
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { renderHook } from '@testing-library/react';
9+
import { createSafeTranslation, useSafeTranslate } from '../useSafeTranslation';
10+
11+
const DEFAULTS = {
12+
'detail.test': 'Test Anchor',
13+
'detail.greeting': 'Hello {{name}}',
14+
};
15+
16+
describe('createSafeTranslation (no I18nProvider)', () => {
17+
it('falls back to defaults with placeholder interpolation', () => {
18+
const useT = createSafeTranslation(DEFAULTS, 'detail.test');
19+
const { result } = renderHook(() => useT());
20+
expect(result.current.t('detail.greeting', { name: 'Ada' })).toBe('Hello Ada');
21+
expect(result.current.t('detail.test')).toBe('Test Anchor');
22+
// Unknown keys pass through as-is (historical contract).
23+
expect(result.current.t('detail.missing')).toBe('detail.missing');
24+
});
25+
26+
it('returns a STABLE fallback t across renders (memo-dep friendly)', () => {
27+
const useT = createSafeTranslation(DEFAULTS, 'detail.test');
28+
const { result, rerender } = renderHook(() => useT());
29+
const first = result.current.t;
30+
rerender();
31+
expect(result.current.t).toBe(first);
32+
});
33+
});
34+
35+
describe('useSafeTranslate (no I18nProvider)', () => {
36+
it('returns the call-site fallback and supports key-chain form', () => {
37+
const { result } = renderHook(() => useSafeTranslate());
38+
expect(result.current('common.total', 'Total')).toBe('Total');
39+
expect(result.current(['common.total', 'dashboard.total'], 'Total')).toBe('Total');
40+
});
41+
});

‎packages/i18n/src/useSafeTranslation.ts‎

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,37 +14,33 @@ export function createSafeTranslation(
1414
defaults: Record<string, string>,
1515
testKey: string,
1616
) {
17-
return function useSafeTranslation() {
18-
try {
19-
const result = useObjectTranslation();
20-
const testValue = result.t(testKey);
21-
if (testValue === testKey) {
22-
return {
23-
t: (key: string, options?: Record<string, unknown>) => {
24-
let value = defaults[key] || key;
25-
if (options) {
26-
for (const [k, v] of Object.entries(options)) {
27-
value = value.replace(`{{${k}}}`, String(v));
28-
}
29-
}
30-
return value;
31-
},
32-
};
17+
// Factory-level fallback: one stable reference per defaults map, so
18+
// downstream useMemo/useCallback deps don't invalidate every render in
19+
// the no-translations case.
20+
const fallbackT = (key: string, options?: Record<string, unknown>) => {
21+
let value = defaults[key] || key;
22+
if (options) {
23+
for (const [k, v] of Object.entries(options)) {
24+
value = value.replace(`{{${k}}}`, String(v));
3325
}
34-
return { t: result.t };
35-
} catch {
36-
return {
37-
t: (key: string, options?: Record<string, unknown>) => {
38-
let value = defaults[key] || key;
39-
if (options) {
40-
for (const [k, v] of Object.entries(options)) {
41-
value = value.replace(`{{${k}}}`, String(v));
42-
}
43-
}
44-
return value;
45-
},
46-
};
4726
}
27+
return value;
28+
};
29+
30+
return function useSafeTranslation() {
31+
// No try/catch around the hook: useObjectTranslation is provider-safe
32+
// (optional context read + react-i18next global-instance fallback), and
33+
// wrapping a hook call in try/catch violates rules-of-hooks — a throw
34+
// after the hook ran would desync hook order on the next render (same
35+
// fix as objectui#2595/#2596; this factory closure just escaped the
36+
// static lint). The testKey probe below carries the actual
37+
// "translations not configured" fallback.
38+
const result = useObjectTranslation();
39+
const testValue = result.t(testKey);
40+
if (testValue === testKey) {
41+
return { t: fallbackT };
42+
}
43+
return { t: result.t };
4844
};
4945
}
5046

@@ -61,14 +57,11 @@ export function createSafeTranslation(
6157
* `tt(['common.total', 'dashboard.total'], 'Total')`.
6258
*/
6359
export function useSafeTranslate(): (keyOrKeys: string | string[], fallback: string) => string {
64-
let t: ((key: string) => string) | undefined;
65-
try {
66-
t = useObjectTranslation().t;
67-
} catch {
68-
t = undefined;
69-
}
60+
// Unconditional hook call (rules-of-hooks) — the hook is provider-safe,
61+
// and a missing translation surfaces as `t(key) === key` per key below,
62+
// never as a throw. Same fix as createSafeTranslation above.
63+
const { t } = useObjectTranslation();
7064
return (keyOrKeys, fallback) => {
71-
if (!t) return fallback;
7265
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
7366
for (const key of keys) {
7467
const v = t(key);

0 commit comments

Comments
 (0)