post(engineering): two ways a JavaScript file stops working and looks fine (EN+ZH) + og/banner
Deploy / build (push) Successful in 23s

Backdated into the 2025-02 gap: pubDate 2025-02-20, updatedDate 2026-10-16 (lastmod honest).
No calendar dates or checkable version pins in the prose — the condition for backdating — and
no internal links, since every post it would cite is dated later and a backdated post may only
link backwards (see project-state B2n). Generators: additive entries only (5 + 17 lines, 0 deletions).
This commit is contained in:
hoelee committed 2026-10-16 13:32:06 +08:00
1 parent db1e75c3b1
commit 2394a7cbee
6 files changed
+301

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+17
View File
@@ -1271,6 +1271,23 @@ BANNERS['authentik-css-greater-than-bug'] = {
],
};
BANNERS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = {
titlebar: "rt - app.js, silently dead",
lines: [
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "rtCheckHandlers() -> []" },
{ t: 'dim', text: "page renders zh-CN exactly as before - that is the problem" }, { t: 'err', text: "TypeError: t is not a function (applyTheme(t) shadowed t())" },
{ t: 'prompt', text: "$" }, { t: 'cmd', text: "?lang=pseudo strings stretched 1.6x" },
{ t: 'ok', text: "no horizontal overflow at 1440px" }, { t: 'hl', text: "assert effects, not intent: every hook really swaps" },
{ t: 'dim', text: "second cause: let LANG declared below its first call (TDZ)" }, { t: 'dim', text: "3 packs from one table - key drift cannot merge" },
],
flow: [
{ n: '1', label: "load the page" },
{ n: '2', label: "assert typeof t" },
{ n: '3', label: "switch to pseudo" },
{ n: '4', label: "assert no overflow" },
],
};
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
let category = 'devops';
+5
View File
@@ -387,6 +387,11 @@ TERMINALS['authentik-css-greater-than-bug'] = `
<div class="line"><span class="prompt">&nbsp;</span><span class="err">selector matches, computed display: list-item</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">read the sheet back out of the CSS parser</span><span class="fix">-> u003e, not ></span></div>`;
TERMINALS['two-ways-a-javascript-file-stops-working-and-looks-fine'] = `
<div class="line"><span class="prompt">$</span><span class="cmd">node --check app.js -> valid, no output</span></div>
<div class="line"><span class="prompt">&nbsp;</span><span class="err">TypeError: t is not a function (a parameter named t)</span></div>
<div class="line"><span class="prompt">$</span><span class="cmd">typeof t === 'function' ?lang=pseudo clean</span><span class="fix">-> script reaches the end</span></div>`;
// ---------- read frontmatter ----------
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
if (!existsSync(postPath)) {
@@ -0,0 +1,175 @@
---
title: "Two Ways a JavaScript File Stops Working and Looks Fine"
description: "A shadowed function name and a let declared below its first call both killed a whole script — and the page rendered perfectly, because nothing had changed on screen."
pubDate: 2025-02-20
updatedDate: 2026-10-16
category: engineering
tags: ["javascript", "i18n", "debugging", "frontend"]
ogImage: /og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
banner: /banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
draft: false
---
## 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:
```js
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:
```js
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:
```js
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:
- 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:
```js
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.
@@ -0,0 +1,104 @@
---
title: "JavaScript 文件静默失效的两种方式:而页面看上去一切正常"
description: "一个被参数名遮住的函数名,加一个声明在首次调用之后的 let,让整段脚本停摆——而页面渲染得完美好看,因为屏幕上什么都没变。"
pubDate: 2025-02-20
updatedDate: 2026-10-16
category: engineering
tags: ["javascript", "i18n", "debugging", "frontend"]
ogImage: /og/two-ways-a-javascript-file-stops-working-and-looks-fine.png
banner: /banners/two-ways-a-javascript-file-stops-working-and-looks-fine.png
draft: false
---
## 一次「什么都没发生」的改动
我给一个自己用的媒体转码队列看板加了语言切换:三份语言包、一个 `t(key, vars)` 函数、界面 chrome 上的 `data-i18n`、顶栏一个 `<select>`。
部署上去之后,页面和之前一模一样。不是坏了——是**毫无变化**。同样的版式、同样的中文标签,没有缺失的面板、没有错误卡片、控制台也没有告警。语言选择器就是不在那里,而新代码本该做的其它事也一件都没发生。
这正是我想写下来的那种 bug:因为页面并不是「大体能用」。**脚本在中途停止执行了——两次,两个互不相关的一行原因——而页面照样渲染得完美好看。**
## 为什么什么都看不出来
有两个设计决定我还会再做一遍,而它们各自都替我把故障藏了起来。
第一个是默认语言写在标记里。中文原文就写在元素内部,`data-i18n` 只是一个「切换语言时覆盖它」的钩子。这是刻意的——页面不依赖任何 fetch 就能正确渲染,语言包丢了你会看到中文,而不是一排 `top.theme_to_dark` 这样的键名。代价是:**一层彻底死掉的 i18n,产出的仍然是视觉上完全正确的页面。**
第二个是按钮用内联处理器接线(`onclick="rtFoo()"`),脚本中断之后它们指向从未被定义的函数。没有任何提示。页面只在有人真的点了某个东西时才吐露真相。
所以症状是「我的改动没有效果」——这是一个网页能给出的信息量最低的症状。
## 故障一:一个参数吞掉了一个函数
这个文件里有一个主题块和一个 i18n 块。主题块原本长这样:
```js
function applyTheme(t) { // t = 主题(theme)
label.textContent = t('top.theme_to_dark'); // TypeError: t is not a function
}
```
`applyTheme` 写在 i18n 层存在之前,那时 `t` 作为「theme」的缩写完全合理。等 i18n 来了,那个作用域里的 `t` 就不再是主题,而变成了**我想调用的那个函数**——但参数把它遮住了,于是这次调用成了「把一个字符串当函数调」。
经典脚本顶层抛出的 `TypeError` 不只让一行失败。它后面的一切都不再运行:语言包加载、`data-i18n` 扫描、切换器渲染。**一个错字母,静默地删掉了一整块功能。**
修法是一个字符——把参数改名成 `th`——外加一行留在原地、给下一个人的注释:
```js
function applyTheme(th) { // 不要叫 t:这个文件里 t() 是 i18n 函数
```
## 故障二:一个来晚了的声明
第二个原因在同一个文件里,而且是个人人都知道、却照样会踩的 JavaScript 规则。主题块在加载时运行并调用 `t('…')`;而 i18n 块在文件更靠下的位置,以这一行开始:
```js
let LANG = localStorage.getItem('rt_lang') || 'zh-CN';
```
`let` 和 `const` 会被提升,但**不会初始化**:从作用域开始到那一行之间,它们待在暂时性死区(TDZ)里,任何访问都会抛 `Cannot access 'LANG' before initialization`。因为主题块就在 i18n 块**上面**,这不是竞态——是每次加载都必然发生。
注意它与 `var` 的区别:`var` 会安静地给我一个 `undefined`,然后页面继续工作。TDZ 是这门语言在阻止我写出即将写出的那个 bug;而它之所以看起来像「什么都没发生」,还是因为标记里那份回退文案。
修法是排序,而这里的排序是**依赖**,不是风格偏好:如果一个块调用了 `t()`,i18n 的声明就必须在它上面。这个文件按主题排布(「先是主题,后是 i18n」),因为它就是这么长出来的。
## 为什么我的检查一个都没抓到
我有测试。它们检查的是**意图**,不是**效果**:
- 三份语言包存在且能解析;
- 标记里用到的每个键,在每份包里都有;
- 每份包里没有死键(拼写错误探测器);
- 三份包的 `{var}` 占位符集合一致。
全部为真,全部为绿。而它们**没有一条会执行页面**,所以没有一条能看见一个在 900 行的第 12 行停摆的脚本。静态检查在结构上就无法捕捉「文件合法、程序已死」这类故障。
## 在浏览器里,断言效果
真正找出这两个 bug 的,是向线上页面问一些只有运行中的程序才能回答的问题:
```js
typeof t === 'function' // 入口点还调得动吗?
document.querySelectorAll('[data-i18n]').length // 钩子还在吗?
rtCheckHandlers() // 内联处理器还解析得到吗?
```
最后那条比看起来更值钱。这个页面带着 **219 个内联 `onclick` 属性,指向 217 个不同的函数**;这条检查把它们逐个枚举,并断言每个名字都在 `window` 上有定义。它把「手点一遍的冒烟测试」变成一个两秒的自动检查,也正是它把「页面看起来是对的」变成了一条可测量的性质。
第二个工具更便宜、也更刻薄:**伪语言**。`?lang=pseudo` 把语言包里每一条文案加上 `[~~` 前缀并拉长 1.6 倍。然后断言两件事——在 1440px 宽下没有横向溢出,以及导航里没有任何元素缺少 `[~~` 前缀(也就是没有任何一处漏翻)。前者抓住只在更长语言里才破的版式;后者抓住根本没接上翻译钩子的节点。它在同一轮里就抓到了一个真的。
诚实的范围说明:伪语言**不能**证明你的马来文翻译好不好。它证明的是**管线完整**——每条文案都流过了机制,版式也扛得住长度。对文字的判断仍然留给人。
## 如果重来一次
1. **在真实浏览器里加载页面,并在相信一个客户端重构成功之前,先对入口点断言 `typeof`。** 一个死掉的脚本,你手上任何静态检查都看不见。
2. **永远不要让参数名遮住同一个文件里会调用的函数。** 一个字母换掉了我整个下午;而那行注释才是防止它重演的部分。
3. **把声明顺序当成依赖,而不是审美。** 如果一个块要调用某个函数或读取某个常量,那个声明就放到它上面——在一段长脚本里尤其如此。
4. **用一张表生成三份语言包。** 这样「键集合一致」是测试而不是希望,漂移根本合不进来。
5. **当一次改动「什么都没做」时,先怀疑脚本,再怀疑改动。** 「什么都没发生」是渲染型页面的默认失败形态,不是你代码没有生效的证据。
## 结果
两个一行原因,在同一个文件里,都是靠浏览器侧的断言找出来的,而不是靠把代码再读一遍。看板现在能在运行时切换语言,默认渲染与改动前逐字节一致,三份语言包出自同一张生成的表——于是少一个键会表现为一条红色的测试,而不是一个安静地保持中文的词。
可以推广的那句话是我一直记着的:**崩溃会告诉你该往哪里看,而静默停摆什么都不告诉你,还长得像成功。** 如果一个功能可以在一个像素都不动的情况下失效,那么「页面看起来还是对的」就不算一条检查。