react-translation-bridge
v1.0.0
Published
Capture browser-translated text (Google Translate, Edge Translate, etc.) into React state so layout-recomputing components stop infinite-looping.
Maintainers
Readme
react-translation-bridge
Capture browser-translated text (Google Translate, Edge Translate, Safari/Firefox built-ins) into React state, so layout-recomputing components like react-truncate-markup, react-virtualized, and chart libraries don't infinite-loop.
The bug
Some components re-measure the DOM after every render: react-truncate-markup, react-virtualized, react-textfit, several charting libs. They assume the DOM still matches what React rendered. Google Translate breaks that assumption. It mutates text nodes in place, which triggers a re-measure, which causes the component to re-render the original text, which Google Translate mutates again. The page hangs.
See react-truncate-markup#65 and facebook/react#11538 for the long-running discussions.
// Hangs when Google Translate is on
<TruncateMarkup lines={2}>
<div>{longText}</div>
</TruncateMarkup>Why existing solutions don't fix it
| Approach | Problem |
|---|---|
| Outgoing translators (react-google-translate, translate-mutation-observer) | Wrong direction. They translate from React, not into it. |
| Crash-prevention monkey-patches and lint rules | Stops the loop, but leaves the text untranslated. |
| <html lang> boolean detection | Tells you whether translation is on, not what each string became. |
| class="notranslate" everywhere | Disables translation entirely, which defeats the point. |
This package takes a different angle. It wraps the protected subtree in notranslate so the browser leaves it alone. Then it renders invisible decoy spans outside that subtree, watches them with MutationObserver, and feeds the translated text back into your component as React state.
Install
npm install react-translation-bridgeReact 16.8+ is required (peer dependency).
Quickstart
import { TranslationBridge, useTranslationBridge } from "react-translation-bridge";
const Greeting = () => {
const { hello } = useTranslationBridge();
return <p>{hello}</p>;
};
export const App = () => (
<TranslationBridge labels={{ hello: "Hello, world!" }}>
<Greeting />
</TranslationBridge>
);Turn on Google Translate. hello becomes "Ahoj, světe!" (or whatever language you picked), <Greeting> re-renders normally, no infinite loop.
For typed access to your labels, pass a type argument to the hook:
type AppLabels = { hello: string };
const Greeting = () => {
const { hello } = useTranslationBridge<AppLabels>();
return <p>{hello}</p>;
};API
<TranslationBridge>
interface TranslationBridgeProps {
labels: Record<string, string>;
children: React.ReactNode;
}Pass your strings to labels as { key: originalText }. The keys are arbitrary; the values are the source strings you want translated. children is rendered inside a notranslate wrapper so the browser translator skips it, and one invisible decoy <span> per label sits outside that wrapper where the translator can pick it up.
useTranslationBridge()
const labels: Record<string, string> = useTranslationBridge();
// or, with a typed shape:
const labels = useTranslationBridge<{ hello: string }>();Returns the current translated values, keyed by the same keys you passed to labels. Starts equal to the input and updates as the browser translator mutates the decoys.
The optional generic is a type assertion at the call site. TypeScript can't cross-check it against the <TranslationBridge labels={...}> prop because the link runs through Context, so make sure the shape matches what you pass in.
Example: react-truncate-markup
import TruncateMarkup from "react-truncate-markup";
import {
TranslationBridge,
useTranslationBridge,
} from "react-translation-bridge";
const TruncatedBody = () => {
const { body } = useTranslationBridge();
return (
<TruncateMarkup lines={3}>
<div>{body}</div>
</TruncateMarkup>
);
};
export const Article = ({ body }: { body: string }) => (
<TranslationBridge labels={{ body }}>
<TruncatedBody />
</TranslationBridge>
);The same shape works for react-virtualized row renderers and chart axis labels.
How it works
┌─────────────────────────────────────────────────────────────┐
│ <TranslationBridge labels={{ hello: "Hello" }}> │
│ │
│ ┌──────────────────────────┐ │
│ │ aria-hidden decoy region │ ← browser translates THIS │
│ │ <span>Hello</span> │ (visible to translator, │
│ │ │ invisible to user) │
│ └──────────────────────────┘ │
│ │ MutationObserver │
│ ▼ │
│ React state: { hello: "Ahoj" } │
│ │ Context │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ <div class="notranslate" │ ← browser SKIPS this │
│ │ translate="no"> │ (React owns it) │
│ │ children │ │
│ └──────────────────────────┘ │
│ │
│ </TranslationBridge> │
└─────────────────────────────────────────────────────────────┘Browser support and caveats
Chrome Translate, Edge Translate, Safari Translate (iOS and macOS), and Firefox full-page translation all work. When the user turns translation off, browsers usually restore the original text on the decoys, so state reverts on its own.
The library is SSR-safe. The MutationObserver setup runs only in useEffect, and the effect short-circuits when the constructor is undefined.
A decoy is one node per labels entry, so keep labels at the granularity you actually need. Splitting too finely (one decoy per word, say) defeats the translator's sentence-level context. When the content of the labels prop changes, the bridge briefly returns the new original values until the translator re-mutates the new decoys; most translators catch up within a few hundred milliseconds. Inline labels={{...}} objects are fine because the component compares structurally, not by reference.
License
MIT. See LICENSE.
