The tap that silently did nothing
A translate button that no-ops instead of failing. Detection fell through to 'en', the translator saw source matching target, and handed the text back.
By Meet ModiA translate action can fail by doing nothing at all: if language detection falls back to a default of 'en' and the target is also 'en', the translator decides there is no work to do and returns the input. No exception, no log, no visible change. The bug is the default, and the reason it is invisible is that every layer below it behaved correctly.
The report
"Translate works for some languages and not others." The split in the report was exact and that is what made it solvable: content in the languages the app had supported for a year translated fine, and content in the three languages we had added that month did nothing when tapped.
Three correct layers and one wrong table
Detection runs in two stages. First a script table, mapping Unicode ranges to language codes, which answers instantly and offline. Then an on-device detector for anything the table cannot name, which is absent in test environments and in plenty of WebViews.
The picker had gained Arabic, Urdu and Thai. The script table still listed only what the previous picker had offered: Devanagari, Bengali, Tamil, Telugu, Kannada, Gujarati. Exactly the split in the report.
// No table entry, no on-device detector:
const detected = scriptTable.match(text) || detector?.detect(text) || "en";
// detected === "en", target === "en"
if (detected === target) return text; // "nothing to do"Every line there is defensible on its own. A fallback so detection always returns something. A short-circuit so you do not pay for a no-op round trip. The combination turns an unsupported language into a successful translation to the language it was already in.
Ordering matters in a script table
Adding the entries is not quite mechanical. Urdu is written in an extended Arabic script, so a general Arabic range matches Urdu text too, and first match wins. Urdu goes first, keyed on the letters that are its own.
// Urdu before Arabic: the general Arabic range would swallow it.
// Same reason Marathi is already listed before Hindi.
{ lang: "ur", test: /[\u0679\u0688\u0691\u06BE\u06D2]/ },
{ lang: "ar", test: /[\u0600-\u06FF]/, ambiguous: true },
{ lang: "th", test: /[\u0E00-\u0E7F]/ },Indonesian and Vietnamese are in the picker too and cannot be added this way at all, because they are written in Latin script. They still need the detector, and on a device without one they will keep failing. That is a real limitation and it is better written in the table than discovered later.
The flag that should not have been inline
There was a short-circuit for very short strings that named Hindi directly, because Hindi and Marathi share Devanagari and two words is not enough to tell them apart. Arabic and Urdu have the same problem, and that condition would not have known.
// Before: the ambiguity lived in a condition, so each new shared
// script meant remembering to edit this line too.
if (text.length < 12 && detected === "hi") return askDetector(text);
// After: the table carries it.
if (text.length < 12 && entry.ambiguous) return askDetector(text);How to make this class of bug loud
The real fix is not the table entries, which will go stale again the next time the picker grows. It is making the fallback distinguishable from an answer.
- Return null from detection when nothing matched, rather than a default, and make the caller decide what an unknown language means.
- Log at error when the table has no entry for text the picker can offer. A warning is for things you expect; this is a configuration gap.
- Pin the invariant in a test: every language the picker lists is either in the script table or explicitly marked as detector-only.
That last one is the one that would have caught it, and it is four lines. The picker and the table are two lists that have to agree, and nothing in the type system was ever going to notice that they had drifted.
Language has a second way of being wrong without saying so: a setting that followed the device rather than the account, which on a shared phone means inheriting a stranger's.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.