Skip to content

Commit 7ffba1c

Browse files
authored
fix runaway effect in useTranslation (#1889)
In the useTranslation.js hook, the loadNamespaces function itself is asynchronous (it fetches translations). The mechanism that triggers the state update (and thus the re-render) is the callback provided to loadNamespaces. Here is the relevant code block from useTranslation.js: // ... const [loadCount, setLoadCount] = useState(0); // <--- 1. State hook // ... useEffect(() => { if (i18n && !ready && !useSuspense) { const onLoaded = () => setLoadCount((c) => c + 1); // <--- 2. Callback updating state if (props.lng) { loadLanguages(i18n, props.lng, namespaces, onLoaded); } else { loadNamespaces(i18n, namespaces, onLoaded); // <--- 3. Trigger } } }, [i18n, props.lng, namespaces, ready, useSuspense, loadCount]); // <--- 4. Dependency array The Cycle of Runaway Effect: 1. Render: useTranslation(['ns1']) is called. A new array ['ns1'] is created. 2. Memoization (Broken): namespaces is memoized with useMemo, but it depends on ns. Since ns is a new reference, namespaces becomes a new reference. 3. Effect: The useEffect has namespaces in its dependency array. React sees it changed, so it runs the effect. 4. Logic: Inside the effect, if !ready (translations not loaded yet), it calls loadNamespaces. 5. Callback: It passes onLoaded, which calls setLoadCount(c => c + 1). 6. Update: loadNamespaces might finish immediately (if cached) or later. It calls onLoaded. 7. Re-render: setLoadCount triggers a re-render of the component. 8. Loop: Back to Step 1. The component re-renders, creates a new ['ns1'] array, namespaces changes, effect runs again, state updates again... Infinite Loop. By stabilizing namespaces, the namespaces reference remains the same across renders even if ns is a new array object. Therefore, useEffect sees no change in dependencies and doesn't run again, breaking the loop.
1 parent 4a3623c commit 7ffba1c

File tree

2 files changed

+38
-4
lines changed

2 files changed

+38
-4
lines changed

src/useTranslation.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,9 @@ export const useTranslation = (ns, props = {}) => {
4343

4444
const { useSuspense, keyPrefix } = i18nOptions;
4545

46-
const namespaces = useMemo(() => {
47-
const nsOrContext = ns || defaultNSFromContext || i18n?.options?.defaultNS;
48-
return isString(nsOrContext) ? [nsOrContext] : nsOrContext || ['translation'];
49-
}, [ns, defaultNSFromContext, i18n]);
46+
const nsOrContext = ns || defaultNSFromContext || i18n?.options?.defaultNS;
47+
const unstableNamespaces = isString(nsOrContext) ? [nsOrContext] : nsOrContext || ['translation'];
48+
const namespaces = useMemo(() => unstableNamespaces, unstableNamespaces);
5049

5150
i18n?.reportNamespaces?.addUsedNamespaces?.(namespaces);
5251

test/useTranslation.spec.jsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,39 @@ describe('useTranslation', () => {
309309
rerender();
310310
});
311311
});
312+
313+
it('should not trigger loadNamespaces on every render if namespaces array is unstable (inline)', async () => {
314+
const i18n = createInstance();
315+
i18n.init({
316+
lng: 'en',
317+
resources: {},
318+
react: { useSuspense: false },
319+
});
320+
321+
// Mock hasLoadedNamespace to return false so we hit the loading path
322+
i18n.hasLoadedNamespace = () => false;
323+
324+
// Mock loadNamespaces to do nothing (pending)
325+
i18n.loadNamespaces = vitest.fn();
326+
327+
let renderCount = 0;
328+
// Create a hook wrapper that forces re-renders by prop
329+
const { rerender } = renderHook(
330+
({ count }) => {
331+
renderCount++;
332+
// Inline array: New reference every render
333+
useTranslation(['ns1'], { i18n });
334+
},
335+
{ initialProps: { count: 0 } },
336+
);
337+
338+
// Initial render
339+
expect(i18n.loadNamespaces).toHaveBeenCalledTimes(1);
340+
341+
rerender({ count: 1 });
342+
expect(i18n.loadNamespaces).toHaveBeenCalledTimes(1);
343+
344+
rerender({ count: 2 });
345+
expect(i18n.loadNamespaces).toHaveBeenCalledTimes(1);
346+
});
312347
});

0 commit comments

Comments
 (0)