Two Ways a JavaScript File Stops Working and Looks Fine

· 6 min read · engineering javascripti18ndebuggingfrontend

The change that did nothing at all

I added a language switcher to a dashboard I run for a media-conversion queue. Three language packs, a t(key, vars) function, data-i18n attributes on the chrome, a <select> in the top bar.

After deploying it, the page looked exactly as it had before. Not broken — unchanged. Same layout, same Chinese labels, no missing panels, no error card, no console banner. The switcher was simply not there, and neither was anything else the new code was supposed to do.

That is the shape of bug I want to write down, because the page was not “mostly working”. The script had stopped executing partway through — twice, for two unrelated one-line reasons — and the page rendered perfectly anyway.

Why nothing looked broken

Two design decisions I would make again, and both of them hid the failure.

The first is that the default language lives in the markup. The Chinese text is written inside the elements; data-i18n is only a hook that overrides it when you switch. That is deliberate — the page needs no fetch to render, and if a language pack goes missing you get Chinese instead of a row of top.theme_to_dark keys. It also means a completely dead i18n layer produces a page that is, visually, correct.

The second is that the buttons are wired with inline handlers (onclick="rtFoo()"), which after a script abort point at functions that were never defined. Nothing warns you. The page only reveals the truth when a human clicks something.

So the symptom was “my change had no effect”, which is the least informative symptom a web page can give you.

Failure one: a parameter that swallowed a function

The file has a theme block and an i18n block. The theme block had this signature:

function applyTheme(t) {                       // t = the theme
  label.textContent = t('top.theme_to_dark');  // TypeError: t is not a function
}

applyTheme was written before the i18n layer existed, and t was a perfectly reasonable name for “theme”. When the i18n layer arrived, t in that scope stopped meaning the theme and started meaning the function I wanted to call — except the parameter shadows it, so the call became “invoke a string”.

A TypeError at the top level of a classic script does not just fail one line. Everything after it never runs: the language-pack loader, the data-i18n sweep, the switcher rendering. One wrong letter removed an entire feature, silently.

The fix is one character — rename the parameter to th — plus a comment where the next person will look:

function applyTheme(th) {   // NOT `t`: t() is the i18n function in this file

Failure two: a declaration that arrived too late

The second cause was in the same file, and it is a JavaScript rule people know and still trip over. The theme block runs at load and calls t('…'). The i18n block, further down the file, began with:

let LANG = localStorage.getItem('rt_lang') || 'zh-CN';

let and const are hoisted but not initialised: between the start of the scope and that line they live in the temporal dead zone, and any access throws Cannot access 'LANG' before initialization. Because the theme block sits above the i18n block, the failure was not a race — it was guaranteed on every single load.

Note the distinction from var, which would have quietly given me undefined and a working page. The TDZ is the language protecting me from exactly the bug I was about to write; the only reason it looked like “nothing happened” is the markup fallback again.

The fix is ordering, and ordering here is a dependency, not a style choice: if a block calls t(), the i18n declarations have to be above it. The file had been organised by topic (“theme, then i18n”) because that is how it grew.

Why my checks did not catch either one

I had tests. They checked intent, not effect:

All true. All green. None of them executes the page, so none of them can see a script that aborts on line 12 of 900. Static checks are structurally unable to catch “the file is valid and the program is dead”.

Assert on effects, in a browser

What found both bugs was asking the live page questions that only a running program can answer:

typeof t === 'function'          // is the entry point still callable?
document.querySelectorAll('[data-i18n]').length   // hooks present
rtCheckHandlers()                // do the inline handlers still resolve?

That last one is worth more than it looks. The page carries 219 inline onclick attributes naming 217 distinct functions; the check enumerates them and asserts each name is defined on window. It turns a hand-clicked smoke test into a two-second automated one, and it is the check that turns “the page looks right” into a measurable property.

The second tool is cheaper and nastier: a pseudo-language. ?lang=pseudo takes every string in the pack, prefixes it with [~~ and stretches it 1.6×. Then assert two things — no horizontal overflow at 1440px, and no element in the nav still lacks the [~~ prefix. The first catches layout that only breaks in a longer language; the second catches nodes that never got a translation hook at all. It found a real one in the same run.

Honest scope note: a pseudo-locale does not prove your Malay translation is good. It proves the plumbing is complete — every string flows through the mechanism, and the layout survives length. Judgement about the words stays with a human.

What I would do differently

  1. Load the page in a real browser and assert typeof on the entry points before believing a client-side refactor worked. A dead script is invisible to every static check you own.
  2. Never let a parameter name shadow a function the same file calls. One letter bought me an entire afternoon; the comment is the part that stops it recurring.
  3. Treat declaration order as a dependency, not aesthetics. If a block calls a function or reads a constant, that declaration goes above it — especially in one long script.
  4. Generate the language packs from one table. Then “the keys match” is a test rather than a hope, and drift cannot be merged.
  5. When a change does nothing, suspect the script before the change. “Nothing happened” is the default failure mode of a rendered page, not evidence that your code was inert.

The result

Two one-line causes, both in the same file, both found by browser-side assertions rather than by reading code again. The dashboard switches language at runtime now, the default render is byte-for-byte what it was before the change, and the three packs come from a single generated table — so a missing key shows up as a red test instead of as a word that quietly stays Chinese.

The generalisation is the part I keep: a crash tells you where to look. A silent stop tells you nothing, and looks like success. If a feature can fail without moving a single pixel, then “the page still looks right” is not a check.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts