We hide your translation keys inside invisible characters

·

...

Dmitrii Bocharov

Senior Software Developer

Upgrading our production Kubernetes cluster

Add these two lines to a component of a React app running against Tolgee in development:

const { t } = useTranslate();
console.log(encodeURIComponent(t('app-title')));
const { t } = useTranslate();
console.log(encodeURIComponent(t('app-title')));
const { t } = useTranslate();
console.log(encodeURIComponent(t('app-title')));

The heading on screen reads "What To Pack". The console reads this:

What%20To%20Pack%E2%80%8C%E2%80%8C%E2%80%8D%E2%80%8D
What%20To%20Pack%E2%80%8C%E2%80%8C%E2%80%8D%E2%80%8D
What%20To%20Pack%E2%80%8C%E2%80%8C%E2%80%8D%E2%80%8D

Twelve characters on screen, thirty in the string. Everything after Pack renders as nothing in a browser, and it's how the SDK knows which key produced that heading.

The thing we wanted

You look at your running app, hold Alt, click on "Save changes", and a dialog opens with that exact key, ready to edit, without guessing whether it was save_changes or dialog.actions.save.

For that to work, the SDK has to answer one question at runtime: which key produced this text on the screen?

Why we couldn't just wrap it

The obvious answer is to return an element instead of a string. We're a React SDK. JSX is right there.

// what we didn't do
return <span data-tolgee-key="save_changes">{translation}</span>;
// what we didn't do
return <span data-tolgee-key="save_changes">{translation}</span>;
// what we didn't do
return <span data-tolgee-key="save_changes">{translation}</span>;

That falls apart on the first real app. People use t() as a string, because it is one:

<img alt={t('logo_alt')} />
<input placeholder={t('email_placeholder')} />
document.title = t('page_title');
const greeting = `${t('hello')}, ${name}`;
<img alt={t('logo_alt')} />
<input placeholder={t('email_placeholder')} />
document.title = t('page_title');
const greeting = `${t('hello')}, ${name}`;
<img alt={t('logo_alt')} />
<input placeholder={t('email_placeholder')} />
document.title = t('page_title');
const greeting = `${t('hello')}, ${name}`;

None of those take an element. And the call sites that would accept one now get a wrapper element in the middle of your layout, breaking flex, :first-child, and whatever else your CSS assumed.

So we had a hard constraint. Whatever t() returns stays a string, and it has to look exactly like the translation on screen.

Two characters that render as nothing

U+200C is the zero width non-joiner (ZWNJ). U+200D is the zero width joiner (ZWJ). Both are normal Unicode, both take no horizontal space in regular text.

Two characters, one bit each. Call ZWNJ zero and ZWJ one, and you have a binary channel that fits inside any JavaScript string.

The translation is never part of that channel. wrap() hands back the string it was given and puts the encoded bits after it:

return value + invisibleMark;
return value + invisibleMark;
return value + invisibleMark;

"What To Pack" comes through untouched. Everything below is about building invisibleMark.

Here's the encoder, straight out of secret.ts:

export const INVISIBLE_CHARACTERS = ['\u200C', '\u200D'];

export function encodeMessage(payload: string) {
  const bytes = toBytes(payload).map(Number);
  const binary = bytes
    .map((byte) => padToWholeBytes(byte.toString(2)) + '0')
    .join('');

  return Array.from(binary)
    .map((b) => INVISIBLE_CHARACTERS[Number(b)])
    .join('');
}
export const INVISIBLE_CHARACTERS = ['\u200C', '\u200D'];

export function encodeMessage(payload: string) {
  const bytes = toBytes(payload).map(Number);
  const binary = bytes
    .map((byte) => padToWholeBytes(byte.toString(2)) + '0')
    .join('');

  return Array.from(binary)
    .map((b) => INVISIBLE_CHARACTERS[Number(b)])
    .join('');
}
export const INVISIBLE_CHARACTERS = ['\u200C', '\u200D'];

export function encodeMessage(payload: string) {
  const bytes = toBytes(payload).map(Number);
  const binary = bytes
    .map((byte) => padToWholeBytes(byte.toString(2)) + '0')
    .join('');

  return Array.from(binary)
    .map((b) => INVISIBLE_CHARACTERS[Number(b)])
    .join('');
}

Note the + '0' at the end of each byte. Every byte becomes 9 invisible characters, not 8. The extra bit is framing.

export const INVISIBLE_REGEX = RegExp(
  `([${INVISIBLE_CHARACTERS.join('')}]{9})+`,
  'g'
);
export const INVISIBLE_REGEX = RegExp(
  `([${INVISIBLE_CHARACTERS.join('')}]{9})+`,
  'g'
);
export const INVISIBLE_REGEX = RegExp(
  `([${INVISIBLE_CHARACTERS.join('')}]{9})+`,
  'g'
);

The reader only accepts runs whose length is a multiple of 9, and only reads the first 8 bits of each group. A stray zero width character in your actual content, and there are plenty out there in Arabic, Hindi and emoji sequences, won't line up and won't be decoded as a key.

Encode a number, not the key

The payload we want to hide is the key, the namespace, and the default value:

const value = { k: data.key, n: data.ns, d: data.defaultValue };
JSON.stringify(value);
const value = { k: data.key, n: data.ns, d: data.defaultValue };
JSON.stringify(value);
const value = { k: data.key, n: data.ns, d: data.defaultValue };
JSON.stringify(value);

For save_changes with no namespace that's {"k":"save_changes"}. Twenty characters of ASCII.

Before any of it gets encoded, one more character goes on the end, a line feed that marks where the payload stops:

function encodeWithSeparator(payload: string) {
  return encodeMessage(payload + MESSAGE_END);
}
function encodeWithSeparator(payload: string) {
  return encodeMessage(payload + MESSAGE_END);
}
function encodeWithSeparator(payload: string) {
  return encodeMessage(payload + MESSAGE_END);
}

Why a line feed for the separator? The comment above it in InvisibleWrapper.ts has the answer. \n gets escaped inside JSON strings, so it can never show up inside an encoded payload. It's free to mean "message ends here" when two translations sit right next to each other in the same text node.

The separator goes on once, around the whole payload. The framing bit from the last section goes on once per byte, inside encodeMessage, and it's why a byte costs 9 characters instead of 8.

So: 21 characters in, 21 bytes, 9 invisible characters each. That's 189 of them trailing a label that is 12 characters long. The hidden part would be fifteen times bigger than the text you can see.

So by default we don't encode the JSON. We encode an index into a per instance dictionary, ValueMemory:

export function ValueMemory() {
  const values: string[] = [];

  return Object.freeze({
    valueToNumber(key: string) {
      let index = values.indexOf(key);
      if (index === -1) {
        index = values.length;
        values.push(key);
      }
      return index;
    },
    numberToValue(num: number) {
      return values[num];
    },
  });
}
export function ValueMemory() {
  const values: string[] = [];

  return Object.freeze({
    valueToNumber(key: string) {
      let index = values.indexOf(key);
      if (index === -1) {
        index = values.length;
        values.push(key);
      }
      return index;
    },
    numberToValue(num: number) {
      return values[num];
    },
  });
}
export function ValueMemory() {
  const values: string[] = [];

  return Object.freeze({
    valueToNumber(key: string) {
      let index = values.indexOf(key);
      if (index === -1) {
        index = values.length;
        values.push(key);
      }
      return index;
    },
    numberToValue(num: number) {
      return values[num];
    },
  });
}

So the payload is no longer the JSON, it's the index: the string 7. That's one byte, plus the line feed separator, so two bytes at 9 characters each. Eighteen invisible characters instead of 189.

One ordering detail worth knowing, because it looks wrong at first. The mark goes on before the ICU formatter runs, not after. It lands at the very end of the string, so plurals and parameters parse exactly as they did before and the invisible tail rides along as literal text.

Getting it back out of the DOM

A MutationObserver watches the document for text and attribute changes:

observer.observe(targetElement, {
  attributes: true,
  attributeFilter: [...monitorAttributeList],
  childList: true,
  subtree: true,
  characterData: true,
});
observer.observe(targetElement, {
  attributes: true,
  attributeFilter: [...monitorAttributeList],
  childList: true,
  subtree: true,
  characterData: true,
});
observer.observe(targetElement, {
  attributes: true,
  attributeFilter: [...monitorAttributeList],
  childList: true,
  subtree: true,
  characterData: true,
});

The attribute filter comes from the observer options, and this is where the string-only constraint pays off:

tagAttributes: {
  textarea: ['placeholder'],
  input: ['value', 'placeholder'],
  img: ['alt'],
  '*': ['aria-label', 'title'],
},
tagAttributes: {
  textarea: ['placeholder'],
  input: ['value', 'placeholder'],
  img: ['alt'],
  '*': ['aria-label', 'title'],
},
tagAttributes: {
  textarea: ['placeholder'],
  input: ['value', 'placeholder'],
  img: ['alt'],
  '*': ['aria-label', 'title'],
},

A <span> wrapper could never live inside an alt attribute. Invisible characters don't care where they are.

Every candidate node gets a cheap test first, before anything expensive happens:

const [ZWNJ, ZWJ] = INVISIBLE_CHARACTERS;

testTextNode(textNode: Text) {
  return textNode.textContent?.includes(`${ZWNJ}${ZWNJ}`)
    || textNode.textContent?.includes(`${ZWJ}${ZWNJ}`);
}
const [ZWNJ, ZWJ] = INVISIBLE_CHARACTERS;

testTextNode(textNode: Text) {
  return textNode.textContent?.includes(`${ZWNJ}${ZWNJ}`)
    || textNode.textContent?.includes(`${ZWJ}${ZWNJ}`);
}
const [ZWNJ, ZWJ] = INVISIBLE_CHARACTERS;

testTextNode(textNode: Text) {
  return textNode.textContent?.includes(`${ZWNJ}${ZWNJ}`)
    || textNode.textContent?.includes(`${ZWJ}${ZWNJ}`);
}

Two adjacent invisible characters. Almost every node in your app fails this check in a few nanoseconds.

The ones that pass get unwrapped. We decode the keys, then write the clean text back into the node:

const result = wrapper.unwrap(oldTextContent);
if (result) {
  const { text, keys } = result;
  setNodeText(textNode, text);
  elementRegistry.register(parentElement, textNode, nodeMeta);
}
const result = wrapper.unwrap(oldTextContent);
if (result) {
  const { text, keys } = result;
  setNodeText(textNode, text);
  elementRegistry.register(parentElement, textNode, nodeMeta);
}
const result = wrapper.unwrap(oldTextContent);
if (result) {
  const { text, keys } = result;
  setNodeText(textNode, text);
  elementRegistry.register(parentElement, textNode, nodeMeta);
}

That last part matters. The mark exists only long enough to be read. Once the observer has seen a node, the DOM holds plain text again, and the key lives in a registry keyed by element.

Which is why you can't find the characters by selecting text in the browser. Anything you can select has already been cleaned. To see them you have to catch the string before it reaches the DOM, in a console.log, or in the server rendered HTML before hydration. And in a production build the mark is never created at all, which is What actually ships, below.

After that, in-context editing is boring. Hold Alt, elementsFromPoint finds the element under the cursor, the registry says which keys it belongs to, a red border goes up, and a click opens the dialog. It works in reverse too. Call highlight(key) or findPositions(key) and you get the bounding boxes of every place that key sits on screen right now. Both are exposed on window.__tolgee while the dev tools are running, so you can poke at this from the console:

window.__tolgee.getVisibleKeys();
window.__tolgee.highlight('app-title');
window.__tolgee.getVisibleKeys();
window.__tolgee.highlight('app-title');
window.__tolgee.getVisibleKeys();
window.__tolgee.highlight('app-title');

That surface is not there for manual debugging. getVisibleKeys() returns every key currently in the viewport together with its bounding box, which is what an automated agent needs to screenshot a screen and upload it to Tolgee with each key already pinned to the right region of the image.

Where React made it harder

Both hard parts come from rendering on the server, and both come back to the dictionary being per instance.

The first is server side rendering: the HTML is built on the server, then the browser hydrates it, running the same components a second time and checking its output against the HTML that arrived. The number the server encoded and the number the browser would encode don't have to match, and a mismatch there is a hydration error.

useTolgeeSSR is the hook that avoids it. You pass your instance through it, and the first render happens with wrapping switched off. Clean strings on the server, clean strings on the first client render, marks from the second render on, so the HTML React compares is identical on both sides.

The second is React Server Components, where there is no second render. The HTML arrives already stringified, and the browser never runs the code that built the dictionary. The number 7 is meaningless there.

That's what fullKeyEncode is for, and it's why the Next.js App Router setup turns it on for the server instance only:

TolgeeBase().init({
  observerOptions: { fullKeyEncode: true },
  language,
});
TolgeeBase().init({
  observerOptions: { fullKeyEncode: true },
  language,
});
TolgeeBase().init({
  observerOptions: { fullKeyEncode: true },
  language,
});

Now the server ships the whole JSON in the invisible payload. Bigger, and worth it, because the client can decode it without knowing anything about the process that produced it.

What actually ships

None of the above is in your production bundle.

DevTools() is a no-op there. @tolgee/web declares a production export condition, so bundlers pick entry-production.ts:

// entry-production.ts
export const DevTools = () => (tolgee: TolgeeInstance) => tolgee;
// entry-production.ts
export const DevTools = () => (tolgee: TolgeeInstance) => tolgee;
// entry-production.ts
export const DevTools = () => (tolgee: TolgeeInstance) => tolgee;

The MUI dialog, the CodeMirror editor, the screenshot tooling, none of it gets bundled. With no plugin to register an observer, wrap() falls through to returning the translation untouched, so nothing is ever appended in the first place. A crawler fetching your site gets exactly the bytes it would have got without Tolgee.

End to end tests are safe in development too, for a different reason: anything a test can query has already been through the observer, so textContent is plain text. The one place the characters are reachable is the return value of t() itself, before it lands in a node, which is what the console.log at the top of this post exploits. Assert on rendered text rather than on the string t() hands back.

That is not the same as "in-context only works on localhost". You can run it against production, and translators usually want exactly that, because staging never looks quite like the real site. It's also how agencies hand editing to their clients.

It just doesn't happen by shipping credentials to everyone. You add BrowserExtensionPlugin instead. For a normal visitor it does nothing at all. When someone with the Tolgee browser extension opens the page, the extension writes credentials into sessionStorage, and the plugin pulls the in-context bundle off the CDN at runtime:

injectPromise = injectScript(
  `${CDN_URL}/@tolgee/web@${version}/dist/${IN_CONTEXT_FILE}`
).then(...);
injectPromise = injectScript(
  `${CDN_URL}/@tolgee/web@${version}/dist/${IN_CONTEXT_FILE}`
).then(...);
injectPromise = injectScript(
  `${CDN_URL}/@tolgee/web@${version}/dist/${IN_CONTEXT_FILE}`
).then(...);

The rule is the same in both cases. The editor is never in your bundle. In development you get it because you built with the dev tools. In production you get it because someone with the extension asked for it.

Where it breaks

If your code slices a translated string, the mark can get cut in half and that string stops being clickable. text-overflow: ellipsis is fine, because that's CSS. t('long_text').slice(0, 40) is not. Same for anything drawn outside the DOM, like Canvas or WebGL, where there is no text node for the observer to read.

There is a second observer mode, observerType: 'text'. It does not fix either of those, but it is worth knowing about, because it works the other way round.

Everything so far has t() returning the translation, with the key hidden on the end of it:

Save changes⟨18 invisible characters⟩
Save changes⟨18 invisible characters⟩
Save changes⟨18 invisible characters⟩

In text mode t() returns a placeholder instead, and no translation at all:

%-%tolgee:
%-%tolgee:
%-%tolgee:

Your app puts that string in the DOM, and the observer replaces it with Save changes when it sees the node. So the translation arrives second, not first.

The placeholder carries everything needed to do that replacement, which is the point: a string rendered on the server can be picked up by the client without the two sharing a dictionary. That is the same problem fullKeyEncode solves, from the other end.

The cost is that the DOM holds the placeholder until the observer gets to it. Anything reading the text before that sees the wrong thing, so document.title won't work, and neither will a tooltip sized from its initial text.

When it does break, it breaks loudly: you get raw %-%tolgee:… on screen instead of text that looks fine but has quietly lost its key.

The takeaway

The whole design came out of refusing to change one type signature. t() returns a string, and we weren't willing to make it return anything else just to make our tooling easier.

That single constraint ruled out wrapper elements, which pushed us to invisible characters, which is what makes the feature work inside alt and title and document.title today. The restriction produced the better design.

Translate your app without losing your mind!

Translate your app without losing your mind!

Code once. Ship globally.

Code once. Ship globally.

Translate your app without losing your mind!