All posts

Why my extension ignored the language users picked

By Tiger Liu · · 3 min read

Mail Toolbox for Gmail™ ships in 27 languages and lets people override the browser language with a manual setting. Before the September 1 release, that setting only half-worked: after choosing a language, the right-click menu switched, but the panel inside Gmail stayed in the browser's language. No error in the console, and the tests were green. Here is why, and the fix I shipped on September 1.

How the language override was built

Chrome's built-in chrome.i18n.getMessage() always follows the browser's UI language. You cannot tell it to use another locale. To support a manual choice, the extension loads the chosen locale's messages.json itself and looks strings up in that dictionary first, falling back to chrome.i18n when there is no dictionary:

const url = chrome.runtime.getURL(`_locales/${code}/messages.json`)
const res = await fetch(url)

The same helper ran in every context: the popup, the options page, the background service worker, and the content script that draws the panel inside Gmail.

Why the menu worked and the panel didn't

A content script runs inside the web page, and its requests carry the page's origin. Fetching a chrome-extension:// URL from there only works if that path is listed under web_accessible_resources in the manifest. _locales/** was not.

So in the content script, the fetch was refused. The loader wrapped it in a try/catch that returned null on failure, which was reasonable for a missing file but hid this case completely. With no dictionary, every lookup fell back to chrome.i18n, which meant the browser language.

The right-click menu, on the other hand, is built in the service worker. Extension pages and the worker can fetch their own files freely, so the menu loaded the dictionary and switched correctly. From the outside it looked like a bug in the panel code, when the panel code was fine.

The fix: ask the background for the dictionary

I considered adding _locales/** to web_accessible_resources. That would work, but it would also make those files readable by the Gmail page itself, and the extension already had a pattern for this situation: when a content script needs something only the extension can access, it asks the service worker.

The content script now sends a message instead of fetching:

async function loadDict(code: string) {
	if (!IN_PAGE) return fetchLocaleDict(code)
	const res = await chrome.runtime.sendMessage({ type: LOCALE_DICT_MSG, code })
	return res?.ok && res.dict ? res.dict : null
}

And the background answers it:

if (msg?.type === LOCALE_DICT_MSG && typeof msg.code === "string") {
	fetchLocaleDict(msg.code)
		.then((dict) => sendResponse({ ok: dict !== null, dict }))
		.catch(() => sendResponse({ ok: false, dict: null }))
	return true // keep the channel open for the async response
}

IN_PAGE is simply a check that the current script is not running on a chrome-extension: page. Extension pages keep reading the file directly; only code inside a website goes through the relay. The manifest did not change.

Why the tests never caught it

This is the part I found most useful. Picture an assertion like "switch the language to Chinese, then confirm the panel shows Chinese". Run it in a browser whose system language is already Chinese, and it passes whether the dictionary loaded or not — the fallback to chrome.i18n produces Chinese anyway. An override test like that can be green for the wrong reason.

Two changes went into the test suite with the fix:

  • A dedicated test for the content-script path, so the relay is covered directly.
  • The test browser is now launched with --lang=en-US. An override test is only meaningful when the override language differs from the browser's own language; pinning the browser to English guarantees that on any machine.

Takeaways

  • A silent catch in a loader turns a permission problem into a wrong-language problem. Log or surface the failure at least in development builds.
  • Content scripts are not extension pages. Anything they fetch from the extension package needs to be web-accessible, or needs to go through the service worker.
  • Tests for a language override must run in a browser whose language is different from the one you switch to. Otherwise the fallback makes them pass for the wrong reason.

The per-feature AI language setting that shipped in the same release is described in the changelog, and Mail Toolbox itself is on its product page.

Gmail is a trademark of Google LLC. Mail Toolbox is an independent product and is not affiliated with Google.

Mentioned in this post

Behind the scenes, once a month

What we shipped, what worked and what flopped across every product we run. One email a month, nothing else.

One click to confirm, then one email a month. Unsubscribe anytime.