Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/i18n-safe-translation-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/i18n': patch
---

fix(i18n): `createSafeTranslation` / `useSafeTranslate` no longer wrap the
translation hook in try/catch — the last known rules-of-hooks violation of
the class fixed in objectui#2595/#2596 (a throw after the hook ran would
desync hook order on the next render; the factory closure just escaped the
static lint). `useObjectTranslation` is provider-safe, and the actual
fallback behavior is unchanged: the testKey probe (createSafeTranslation)
and per-key `t(key) === key` detection (useSafeTranslate) still return the
English defaults when translations aren't configured. The fallback `t` is
now a stable per-factory reference, so downstream memo deps stop
invalidating every render in the no-translations case.
41 changes: 41 additions & 0 deletions packages/i18n/src/__tests__/useSafeTranslation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Locks the fallback semantics of the safe-translation hooks AFTER the
* try/catch-around-hook removal (rules-of-hooks, objectui#2595/#2596 class):
* with no I18nProvider mounted, consumers must still get English defaults —
* via the testKey probe / per-key detection, not via a caught throw.
*/
import { describe, it, expect } from 'vitest';
import { renderHook } from '@testing-library/react';
import { createSafeTranslation, useSafeTranslate } from '../useSafeTranslation';

const DEFAULTS = {
'detail.test': 'Test Anchor',
'detail.greeting': 'Hello {{name}}',
};

describe('createSafeTranslation (no I18nProvider)', () => {
it('falls back to defaults with placeholder interpolation', () => {
const useT = createSafeTranslation(DEFAULTS, 'detail.test');
const { result } = renderHook(() => useT());
expect(result.current.t('detail.greeting', { name: 'Ada' })).toBe('Hello Ada');
expect(result.current.t('detail.test')).toBe('Test Anchor');
// Unknown keys pass through as-is (historical contract).
expect(result.current.t('detail.missing')).toBe('detail.missing');
});

it('returns a STABLE fallback t across renders (memo-dep friendly)', () => {
const useT = createSafeTranslation(DEFAULTS, 'detail.test');
const { result, rerender } = renderHook(() => useT());
const first = result.current.t;
rerender();
expect(result.current.t).toBe(first);
});
});

describe('useSafeTranslate (no I18nProvider)', () => {
it('returns the call-site fallback and supports key-chain form', () => {
const { result } = renderHook(() => useSafeTranslate());
expect(result.current('common.total', 'Total')).toBe('Total');
expect(result.current(['common.total', 'dashboard.total'], 'Total')).toBe('Total');
});
});
65 changes: 29 additions & 36 deletions packages/i18n/src/useSafeTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,37 +14,33 @@ export function createSafeTranslation(
defaults: Record<string, string>,
testKey: string,
) {
return function useSafeTranslation() {
try {
const result = useObjectTranslation();
const testValue = result.t(testKey);
if (testValue === testKey) {
return {
t: (key: string, options?: Record<string, unknown>) => {
let value = defaults[key] || key;
if (options) {
for (const [k, v] of Object.entries(options)) {
value = value.replace(`{{${k}}}`, String(v));
}
}
return value;
},
};
// Factory-level fallback: one stable reference per defaults map, so
// downstream useMemo/useCallback deps don't invalidate every render in
// the no-translations case.
const fallbackT = (key: string, options?: Record<string, unknown>) => {
let value = defaults[key] || key;
if (options) {
for (const [k, v] of Object.entries(options)) {
value = value.replace(`{{${k}}}`, String(v));
}
return { t: result.t };
} catch {
return {
t: (key: string, options?: Record<string, unknown>) => {
let value = defaults[key] || key;
if (options) {
for (const [k, v] of Object.entries(options)) {
value = value.replace(`{{${k}}}`, String(v));
}
}
return value;
},
};
}
return value;
};

return function useSafeTranslation() {
// No try/catch around the hook: useObjectTranslation is provider-safe
// (optional context read + react-i18next global-instance fallback), and
// wrapping a hook call in try/catch violates rules-of-hooks — a throw
// after the hook ran would desync hook order on the next render (same
// fix as objectui#2595/#2596; this factory closure just escaped the
// static lint). The testKey probe below carries the actual
// "translations not configured" fallback.
const result = useObjectTranslation();
const testValue = result.t(testKey);
if (testValue === testKey) {
return { t: fallbackT };
}
return { t: result.t };
};
}

Expand All @@ -61,14 +57,11 @@ export function createSafeTranslation(
* `tt(['common.total', 'dashboard.total'], 'Total')`.
*/
export function useSafeTranslate(): (keyOrKeys: string | string[], fallback: string) => string {
let t: ((key: string) => string) | undefined;
try {
t = useObjectTranslation().t;
} catch {
t = undefined;
}
// Unconditional hook call (rules-of-hooks) — the hook is provider-safe,
// and a missing translation surfaces as `t(key) === key` per key below,
// never as a throw. Same fix as createSafeTranslation above.
const { t } = useObjectTranslation();
return (keyOrKeys, fallback) => {
if (!t) return fallback;
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
for (const key of keys) {
const v = t(key);
Expand Down