DEV Community

Hoelee
Hoelee

Posted on Originally published at blog.hoelee.com

Two Ways a JavaScript File Stops Working and Looks Fine

Two Ways a JavaScript File Stops Working and Looks Fine

Originally published on my blog — read it there with the full context, code and updates.

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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:

  • the three language packs exist and parse;
  • every key used in the markup exists in every pack;
  • no pack contains a dead key (a typo detector);
  • the {var} placeholder sets in the three packs match.

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?
Enter fullscreen mode Exit fullscreen mode

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.


I write about self-hosted infrastructure, DevOps and the failures behind them at blog.hoelee.com.

Top comments (0)