-
Notifications
You must be signed in to change notification settings - Fork 2
Gotchas
真實踩過、花過時間的陷阱。每則:症狀 → 成因 → 修法。依代價排序,不寫通則性建議。
一至十三來自 2026-07-20 那天 —— 九個功能以平行分支同時開發、合併、上線,再回頭修 bug。密集操作把平時遇不到的坑一次踩滿。
十四至十七來自 2026-07-29 做策展影片功能的那次。其中第十四則(Safari 白屏)與該功能無關,是既有的問題被順帶挖出來的 —— 排查過程本身就是那一則的教訓。
十八至二十五來自 2026-08-06~09 那批(電子紙模式、手把、讀書計畫、作答紀錄)。這批的共同點跟前兩批不同:bug 本身不難修,難的是「看不見」 —— 現象在開發機上量不到、測試綠著、而使用者只能說「怪怪的」。所以這幾則的重點多半在怎麼找,不在改哪一行。
二十六是歷次的零星雜項,持續累加。
症狀:推送後 GitHub Actions 三支 workflow 全部 success,但正式站行為完全沒變。
成因:CI 是 path-gated 的 —— deploy-worker.yml 只在純 worker/** 的推送才部署,deploy-pages.yml 只在純 frontend/**。一次同時動到兩邊的推送,兩個 guard 互相把對方擋掉,各自印出 skipping auto-deploy 然後以 success 收場。綠勾的語意是「workflow 成功執行了它的判斷」,不是「已部署」。
修法:同時動到 worker 與 frontend 時,一律手動部署兩邊。要確認到底有沒有真的部署,看 log 而不是看勾:
gh run view <id> --log | grep -i "skipping\|Version ID\|Deployment complete"症狀:九個 PR 都顯示 MERGED,但 git merge-base --is-ancestor feat/x main 對每一個都回 false。差點據此認定工作沒合併進去。
成因:用 --rebase 合併會改寫 commit SHA,原分支的 tip 自然不是 main 的祖先。--is-ancestor 問的是「同一顆 commit 在不在」,而 rebase 之後那顆 commit 已經不存在了。
修法:用 patch-id 比對內容而不是 SHA:
git cherry main feat/x # 前綴 + 的才是「內容不在 main」注意空 commit 會被 rebase 丟掉,所以 git cherry 可能列出一個永遠對不上的空 commit —— 用 git show --stat 確認它沒有檔案變更即可。
代價最高的一個 bug,而且症狀完全不像成因。
症狀:個人筆記在唯讀檢視下,選字之後的「螢光標記」popup 不會出現。裝置發燙。
成因:AnnotatableContent 兩個 effect 的依賴陣列放的是 content 物件,而 NoteContent 的 SectionBody 每次 render 都重建一個新的 doc:
<SectionBody doc={{ type: 'doc', content: buf }} /> // 每次 render 都是新物件於是每次 render 都執行 editor.commands.setContent() —— 那會清掉選取範圍,而 popup 正是建立在選取上的,mouseup 還來不及處理選取就沒了。更糟的是同一個 effect 會 setDocRev(r => r + 1) 觸發 render,再產生新物件,再跑 effect:閉環。第二個 effect 則是每輪打一次 reconcileHighlight,變成每 render 一個網路請求。
詳解那條路徑沒事,因為 explanationJson 有 useMemo,識別穩定。只有筆記會踩到。
修法:依賴改成內容雜湊而非物件識別 —— 文字真的變了才重跑:
}, [baseHash, editor, storeKey]); // 不是 [content, ...]推論:任何把 content / doc / 陣列 當 effect 依賴的地方,先問「呼叫端每次 render 會不會給新物件」。這條在 TipTap 這種「effect 內會改動 editor 狀態」的元件上特別致命,因為改動本身會再觸發 render。
症狀:自動挖空按下去跑很久,最後回「AI 這次挑不出關鍵詞」。
成因:Workers AI 的 structured output 是文法受限解碼。schema 寫 minItems: 10 等於強迫模型一直產出直到湊滿下限,在 6000 字的輸入上會跑很久,然後撞上 max_tokens,JSON 陣列沒收尾,解析失敗。
修法:只設上限,密度改用 prompt 要求 —— prompt 沒達成不會爆,schema 沒達成會。修完同一份輸入 2.6 秒回 40 個詞。
症狀:同上。使用者看到的是「AI 挑不出關鍵詞」,實際上是呼叫失敗。
成因:
} catch { terms = []; } // 所有錯誤 → 空陣列 → 「挑不出」服務中斷與「真的沒有可挑的詞」變成同一個畫面,無從分辨,也不會留下任何線索可查。
修法:錯誤要 console.error(wrangler tail 才看得到),回傳的 reason 要能區分 ai_error 與 ai_empty,並且失敗時降級重試一次(較小視窗、較少數量)—— 稀疏的結果勝過沒有結果。
症狀:自動挖空的空格重新整理就消失,得再按一次按鈕——看起來像「這功能不會保存」。
成因:關鍵詞其實一直存在 D1(explanation_cloze 全站共用、note_cloze 每人一份)。缺的是**「這位讀者在這題開過自動挖空」這個事實沒有被記錄**,所以重新載入後前端狀態歸零。更糟的是,當時再按一次會重新花一次 Workers AI 額度去算出一模一樣的結果。
修法:記住開關(每題 × 每區塊),並新增 cached_only=1 讀取模式——只讀快取、絕不呼叫 AI。快取真的失效時(筆記被改、prompt 版本變動)就乾淨地回未挖空狀態,而不是偷偷再算一次。
刻意沒選的簡單解:「伺服器有詞就還原」。那是錯的——explanation_cloze 是每份詳解版本一列、全站共用,所以別人按過自我測驗之後,你一打開詳解就被挖了一堆空,而你根本沒要求。要記的是「你開過」,不是「有人算過」。
推論:使用者說「這個不會保存」時,先確認到底是哪一層沒保存。資料層、狀態層、還原路徑是三件事,缺任何一件症狀都一樣,但修法完全不同。
症狀:匯出 200 題時 D1_ERROR: too many SQL variables。
成因:… IN (?, ?, …) 展開的參數量超過 D1 上限(實測約 100)。
修法:任何把清單展開成 placeholder 的地方都要有天花板。集中在 worker/lib/sql-params.ts:chunkParams() 分批(90,留餘裕給同句的其他參數)、parseTagList() 對使用者輸入的逗號字串設上限。
推論:當時還有三處使用者可控的 tag 清單沒有上限(/api/search、/api/questions、匯出 scope)—— 逗號打夠多就是一個任何人都能觸發的 500。找到一處這種 bug 時,一定要全 repo 掃同類。
症狀:session 過期後,API 回應變成 Access 登入頁的 HTML,卻被當成成功資料處理。
成因:worker 自己一律回 401 JSON(worker/lib/auth.ts),但 302 是邊緣做的,worker 根本沒被呼叫到。fetch 預設跟隨轉址,於是拿到:
res.status === 200
res.ok === true ← status 完全不能用來判斷
res.redirected === true
res.url host 是 *.cloudflareaccess.com
content-type text/html
sequenceDiagram
participant P as 頁面 / SW
participant E as Cloudflare 邊緣(Access)
participant W as Worker
P->>E: GET /api/…(session 已過期)
E-->>P: 302 → *.cloudflareaccess.com
Note over W: Worker 從未被呼叫<br/>它的 401 JSON 不會發生
P->>E: fetch 自動跟隨轉址
E-->>P: 200 text/html(登入頁)
Note over P: res.ok === true<br/>看 status 只會被騙
frontend/src/lib/api.ts 原本在 JSON parse 失敗時把純文字塞進 data 回傳 —— 登入頁 HTML 就這樣被當成 API 資料。匯出功能更慘:會把登入頁存成檔案下載給使用者。
修法:判斷條件看 res.redirected / 跨源 res.url / content-type,永遠不要看 status。service worker 尤其致命:登入頁一旦被寫進 cache,使用者每次開 app 都看到快取的登入頁,而且 SW 不再碰網路,無法自我修復。因此 SW 的 cacheWillUpdate 用自寫的守衛(workbox 內建的 cacheableResponse 只看 status,看不出這件事),並採 fail-safe:判斷不出來就當成是登入頁、不要快取。
症狀:PWA 裝不起來;SW 更新因 MIME 錯誤永久失敗。
成因:scripts/setup-public-bypass.sh 建立的 bypass app 是逐條精確路徑。清單裡有 / 不代表 /manifest.webmanifest、/sw.js、/icons/* 也通(收尾驗證明寫 /api/health 仍應 302,正可佐證)。
修法:新增任何必須在未登入狀態下取得的靜態資源,都要進 bypass 清單。sw-kill.js 一定要 bypass —— 那是 SW 出事時唯一的剎車,而出事的人通常正卡在登入不了的狀態。
順帶一提:precache 清單裡的 /index.html 在自訂網域上回 308(Pages 正規化到 /),而不是 200。
症狀:在新 worktree 裡 pnpm build 直接壞。
成因:config.toml、wrangler.toml、.dev.vars 都在 .gitignore 裡,乾淨簽出的 worktree 不會有它們。而 frontend/vite.config.ts 在 build time 要讀 config.toml 注入 __APP_CONFIG__。
修法:worktree 開好後先從主工作區複製,再 pnpm install:
cp /path/to/main/{config.toml,wrangler.toml,.dev.vars} .
pnpm install && (cd frontend && pnpm install)同理:需要新增 worker 環境變數時,wrangler.toml 的改動進不了 PR。要改的是 wrangler.example.toml + scripts/setup.sh 的鏡射機制,並在 PR 描述寫清楚整合者要手動補哪幾行。
症狀:六份獨立寫成的實作計畫,全部聲稱要用 0023。
成因:每個人(或每個 agent)都正確地查了「目前最後一號」,然後都挑了下一號。
修法:平行動工前由整合者全域分配唯一編號,並在每份計畫寫明「動手前重新確認 ls migrations/ | sort | tail -1」。編號本身沒有順序語意 —— D1 逐支記錄已套用的 migration,晚合併的小號照樣會被套用。
症狀:(未發生,合併前攔下)兩個功能各自註冊 GET /api/review/pacing。
成因:Hono 不會對重複註冊報錯,後者直接吃掉前者。兩個分支各自 build、各自測試都會過,合併後才炸,而且症狀是「某個功能莫名其妙沒了」。
修法:平行分支動工前先讀當下的 main,不要照計畫寫成時的行號與路由表施工。這也是為什麼分波合併(每波併回 main 後下一波才分出)比九路齊發安全。
貫穿當天的教訓。287 個 worker 測試 + 79 個前端測試全過、tsc 零錯誤、build 乾淨 —— 而同時:自動挖空在正式站完全壞掉、個人筆記的畫記 popup 消失、PWA 的重新載入按鈕沒反應。
原因很簡單:測試涵蓋的是純函式,壞掉的是整合與 React 生命週期。
實際有效的驗證方式:
pnpm exec wrangler dev --port 8787 # 本地 worker(會 bypass Access)
curl -H "X-Dev-Email: you@example.com" "http://127.0.0.1:8787/api/…"本地 dev 的 Workers AI 是直接打真的 API,所以連 AI 行為都能實測。自動挖空的根因就是這樣三分鐘定位的 —— 而在那之前已經憑猜測改了兩輪。
推論:改完 AI / 整合 / 前端生命週期相關的東西,「跑起來打一次」的成本遠低於讓使用者當測試員。
代價最高的一則 —— 它活了不知多久,而且從來沒被回報成「Safari 問題」。
症狀:iPhone / iPad / 桌機 Safari 開任何題目頁都是整頁全白。Chromium 一切正常。使用者的描述是「進入題目頁會閃退」。
成因:@tiptap/react 的 useEditor() 可能先回一個 isDestroyed === true 的 Editor,下一次 render 才換成活的。這個中間狀態很難察覺:editor 不是 null、editor.view 也還在,只有 editor.view.docView 已經是 null。
AnnotatableContent 每個 effect 的守衛都是 if (!editor) return —— 只擋 null,於是放行,接著往這個空殼呼叫 registerPlugin:
TypeError: null is not an object (evaluating 'this.docView.matchesNode')
關鍵在這個例外丟在 React 的 commit 階段。React 18 對 commit 階段的未捕捉例外只有一種處理:卸載整棵樹。所以症狀不是「某個區塊壞掉」,而是 <div id="root"> 被清空。
sequenceDiagram
participant R as React commit
participant H as useEditor
participant A as AnnotatableContent effect
participant P as ProseMirror view
H-->>A: editor(isDestroyed = true)
A->>A: if (!editor) return —— 不是 null,放行
A->>P: registerPlugin → view.updateState()
P--xA: this.docView 是 null → TypeError
Note over R: commit 階段的未捕捉例外
R->>R: 卸載整棵樹 → 整頁白屏
會不會命中、命中多久,取決於 JS 引擎排程微任務與 React 排程 passive effect 的相對順序 —— WebKit 穩定命中,Chromium 量不到。而 iOS 強制所有瀏覽器使用 WebKit,所以「只有手機壞」,換 Chrome 也一樣。
修法:守衛要同時擋 null 與已銷毀,寫成 type predicate 讓 TypeScript 也收窄:
function live(editor: Editor | null): editor is Editor {
return !!editor && !editor.isDestroyed;
}不需要重試、不需要 setTimeout、更不該用 error boundary 蓋住 —— effect 的 deps 含 editor,第二次 render 帶著活的實例會再跑一次同一個 effect,該做的事那時候會完成(實測第二次 destroyed: false 且註冊成功,自動挖空沒有被關掉)。非同步回呼(reconcileHighlight().then())要進去後再檢查一次,守衛只擋得住進入時。
推論:這條之所以能活這麼久,是因為驗證一直只跑 Chromium。UI 改動要驗,就用 playwright 的 webkit + devices['iPhone 13'] 打正式建置產物 —— dev server 有 StrictMode 雙掛載,訊號不一樣(會多冒一個 Adding different instances of a keyed plugin,那是 StrictMode 的產物,不是根因)。呼應第十三條:測試全綠不代表功能會動,而「只在一個引擎上綠」更不代表。
症狀:影片策展跑完,93 個主題有 28 個一支影片都不剩。看起來像年限門檻設太嚴 —— 於是去調門檻,調完更糟(去重影片從 195 支掉到 138 支)。
成因:當時是「flat 搜尋 → 逐支 yt-dlp <url> --dump-json 補 upload_date」。每支影片開一個 process、4 併發,YouTube 直接節流,82% 的補抓回空(837 支只成功 148 支)。而 _ytdlp() 對失敗一律回空 list,所以每一支都只是「沒有 metadata」→ 被過濾掉 → 症狀完全長得像門檻問題。
修法:改用非 flat 的 yt-dlp "ytsearch20:<query>" —— 同一個 process 內解析 20 支,約 1 秒/支,upload_date / description / availability 一次到位,而且不觸發節流。去重影片 138 → 745,空主題 28 → 0。
推論:「回空」與「失敗」走同一條路徑而不可分辨時,診斷一定會走錯方向。批次抓外部資料時要把失敗率印出來 —— 這次是量了快取才發現 82% 是空的,在那之前已經憑猜測改了一輪門檻。與第五條同一個病:把錯誤吞成空結果。
症狀:治療類的主題全部空的(anticoagulation 12 支候選 → 0 支存活)。
成因:影片年限被設計成硬過濾 —— 治療類 5 年、機轉類 12 年。但 YouTube 上的醫學教學影片絕大多數比 5 年老,硬牆等於把整類主題刪掉。當初挑這個數字時沒有先看資料分佈。
修法:改成兩段 —— 先收偏好年限內的,不足 5 支才依觀看數從較舊的池子回填,到硬上限(治療 10 年 / 機轉 18 年)為止。卡片上顯示上傳年份,新舊由讀者自己判斷。
推論:品質門檻在資料分佈未知時,先做成排序權重或回填規則,不要做成硬過濾。硬過濾的失敗是靜默的:你只看得到空清單,看不到「被刷掉的其實只差一點點」。
症狀:scripts/data/ 長到 995 MB,而且每次跑 search 都要 parse 一個 942 MB 的 JSON。
成因:快取直接寫 yt-dlp --dump-json 的原始輸出。單支約 600 KB,其中 automatic_captions 一項就佔 500 KB、formats 再 70 KB —— 全都用不到。
修法:在 _ytdlp() 出口就投影成 11 個真正會讀的欄位(scripts/curate-videos.py 的 slim())。同一份資料 3.4 MB,重跑候選數完全一致(815 支 / 去重 745 支)。
這批裡最貴的一則 —— 連錯四個假設。
症狀:電子紙模式下,BOOX 的內建瀏覽器上「上一題/下一題、分頁列、收藏、手風琴」都有一塊底色。但顏色掃描在全站 30 種組合(兩個寬度 × 各路由 × 各分頁)全綠,桌機 Chromium 連讀像素都是白的。
四個被實機逐一否證的假設:-webkit-tap-highlight-color 殘影 → 早已宣告 transparent;UA 預設 focus ring → 是真的破口(outline: auto 1px rgb(16,16,16),深灰雙色環)但不是這個症狀;原生控制項繪製(-webkit-appearance: button)→ 診斷頁上裸 button 完全正常;accent-color: #000 / color-scheme → 逐一隔離後全白。
成因:那塊底色是瀏覽器自己畫的,不出現在任何 API 裡 —— getComputedStyle 讀到的永遠是 preflight 給的 rgba(0,0,0,0),而桌機引擎根本不畫。三層偽裝疊在一起,靠推理走不到終點。
破案的是使用者給的四筆對照,而且它們的 class 幾乎一樣:
| 元素 | tag | class 含 bg-
|
現象 |
|---|---|---|---|
| 民國 xx 年 | a |
✗ | 正常 |
| 上一題/下一題 | button |
✗ | 有底色 |
| 複製為 Markdown | button |
✓ hover:bg-ink-100
|
正常 |
| 收藏 | button |
✗ | 有底色 |
第一列和第二列的 class 一字不差,只差在 tag;兩顆 button 之間只差一個 hover:bg-。能同時解釋這四筆的規則只有一條:是 <button> 且作者沒給不透明背景。「複製」之所以沒事,純粹因為那個 hover:bg- 讓中和層通則命中它、塗上不透明 #fff,把底下那塊蓋住了 —— 是意外,不是設計。
修法:不去證明兇手是誰,直接把已知有效的條件套到每顆按鈕上 —— 通則對有 bg- 的按鈕做什麼,就對其餘的做什麼。transparent 不算數(等於沒背景,蓋不住東西),必須不透明。
推論:掃描全綠而使用者說有問題時,「有問題的」與「沒問題的」兩組之間的差異,就是唯一還在運作的儀器。 要問的不是「哪個元件壞了」,是「這兩組差在哪一個屬性」。也不必等根因水落石出才動手 —— 對照組裡那個「沒問題的」本身就帶著一份可複製的處方。
症狀:接續上一則。前兩輪我在一台量不到該現象的機器上推理,各燒掉一輪。
成因:本機 Chromium 不畫那塊底色,所以任何本機量測都只會回報「一切正常」。在這種條件下推理,等於用一把測不到的尺反覆量同一個東西。
修法:把差異化實驗做成一頁 HTML,部署到正式站,請使用者在出問題的裝置上打開。四頁下來收斂得很快:
| 頁 | 問什麼 | 得到的答案 |
|---|---|---|
| 1 | 九種按鈕變體(preflight/appearance/白底/all:unset/div role=button) |
全部正常 → 不是瀏覽器畫的 |
| 2 | 同樣的按鈕,載入 app CSS + class="eink"
|
全部變黑 → 是 app CSS |
| 3 |
accent-color/color-scheme/caret-color/appearance 一格一個 |
全部正常 → 這四個都洗清 |
| 4/5 | 讓瀏覽器自己列出命中的規則;一鍵套用候選修法 | — |
兩個實務細節:電子書上複製貼上不現實,所以頁面要設計成「只需要回報哪幾號」或「點一下看有沒有變」,不要求貼結果;每頁都要有一格已知答案的對照組(例如「你說正常的那個 <a>」),否則無法分辨「修好了」與「這頁根本沒生效」。
事後清乾淨:診斷頁在收斂後從 repo 與正式站一併移除。真的再需要時重建比維護便宜 —— 每次要問的問題本來就不一樣。
測試綠著但什麼都沒驗到。三次的機制完全不同,只有一個共同的解法。
(a) 正面斷言數錯東西。 焦點外框的測試先數「量到幾個有外框的元素」當防腐劑,但把 focus:outline-none 那些 2px 透明外框也計進去了。全站 30 處這種 utility,所以那個數字永遠 > 0,防腐劑失效。改成只數看得見(alpha === 1)的外框才有意義。
(b) 測在錯的位置。 作答紀錄遺失,我照直覺測「回到這一題時有沒有重抓」——量到 0 次,測試綠。但覆蓋根本不發生在回來的時候(見下一則)。斷言為真,結論卻是錯的。
(c) 儀器本身壞了。 要列出所有含 eink 的 CSS 規則,用了 if (r.cssRules) { walk(...); continue; } 判斷群組規則 —— 但支援 CSS Nesting 的引擎(Chromium)給每一條 CSSStyleRule 都掛了 cssRules,值是空的 CSSRuleList,空歸空,它是 truthy。於是所有普通規則都被當成群組、遞迴進空清單然後跳過,一條都收不到。診斷腳本讀得到 72 條,測試讀到 0 條。
修法:只有一條 —— 每支新測試都要先確認它在功能壞掉時會紅。做法是暫時把修法拿掉、重建、跑一次,看見紅燈再還原。這輪三次假綠全部是靠「至少要量到 N 個」這類正面斷言擋下來的,沒有它就是三支永遠全綠的空測試。
pnpm build >/dev/null 2>&1)。建置失敗被吃掉的話,測試會跑在舊 bundle 上,得到「停用了還是綠」的假結論 —— 那會讓你以為測試無效而把它刪掉。
症狀:作答完,用上一題/下一題來回切一下,作答紀錄就不見了。
成因:直覺會找「回到這題時重抓,把它洗掉了」—— 但回來時根本不會重抓:questionCache 的 TTL 內 peek() 直接命中,一次網路都不發。真正動手的是離開的時候:Question.tsx 在鄰居題上閒置時會預抓它自己的鄰居,而剛作答那一題正好是其中之一。預抓回來的 payload 被無條件 set() 進快取,蓋掉 withAnswer() 就地寫入的紀錄;等使用者切回來,peek() 拿到的已經是被蓋過的版本。
線上為什麼會拿到「沒有作答紀錄」的 payload:/api/questions/:id 在 Service Worker 是 NetworkFirst + 3 秒 timeout,弱訊號(e-ink 平板正是這個情境)下回的是答題前存下的那份快取。伺服器其實記得,但那趟請求根本沒到伺服器。
找到它的方法:把「作答 → 等待 → 離開 → 回來」每一階段的 payload 請求數與畫面狀態逐段印出來。到鄰居題 那一行 +1 就是兇手:
② 作答完成 113-050 累計 1 (+0) 畫面:「答對了」在
③ 等 65 秒(TTL 過期) 113-050 累計 1 (+0)
④ 到鄰居題 113-050 累計 2 (+1) ← 這裡被抓回來了
⑤ 回到 113-050 113-050 累計 2 (+0) 畫面:「答對了」不見
修法:preserveLocalAnswer() 掛在 questionCache 的 fetcher 上 —— 那是背景重抓、reload({force})、閒置預抓三條路徑的唯一交會點;掛在呼叫端會漏掉預抓那條,而它正是實際出事的那條。判準收得很窄:只有「對方沒有 last_chosen 而本地有」才保留,伺服器有紀錄時一律以它為準。
推論:查快取被污染的問題時,先列出所有會寫入快取的路徑,而不是只看「使用者現在在哪一頁」。預抓、背景重抓這類「使用者沒感覺」的寫入,正因為沒感覺才最難聯想。
症狀:預抓與樂觀更新的兩支 e2e 在本機穩定綠(實測 37–119ms,門檻 300ms,看起來有 2.3 倍餘裕),在 GitHub Actions 上一起紅。機制完全正常。
成因:CI runner 慢得多,光是頁面本身的工作就吃掉 600ms。量牆上時鐘就是在量那台機器,不是在量這段程式。
修法:改成斷言「有沒有再打一次網路」——
- 預抓:點擊到畫面換好之間,伺服器有沒有再收到這一題的 payload 請求(只算 payload,不算
/comments/similar/videos,那些本來就是換題後才抓) - 樂觀更新:看見「答對了」的當下,那趟 POST 有沒有 Resource Timing 條目(條目在請求完成時才寫入)
結果只剩兩種:機制有效,或畫面根本不會出現 —— 跟機器快慢完全無關,而且比「夠快」是更強的證據。
推論:2.3 倍的餘裕不是餘裕。 任何以時間為門檻的斷言,都是在對「這支測試未來會在什麼機器上跑」下賭注。
症狀:電子紙模式下,展開的手風琴裡,父標題的焦點環只剩縮排露出的一小段(678px 寬的按鈕底邊只剩 28px 可見)。
成因:焦點環(不論是 ring 的 box-shadow 還是 outline)畫在元素自己那一步,而 in-flow 的後續兄弟在 tree order 之後才畫自己的背景 —— 展開的手風琴裡,子標題按鈕正是父標題按鈕的後續兄弟。light/dark 下那些背景是透明的所以蓋不住東西;e-ink 因為「每顆按鈕都給不透明白底」(見十九),每個子標題都帶著一塊實心白。
修法:換成 outline 解不掉 —— Blink 沒有把 outline 提到 stacking context 的最後畫。唯一有效的是把聚焦元素本身變成 positioned(position: relative; z-index: 1),讓它整個晚於 static 兄弟繪製。已定位的元素要排除,否則會把版面弄壞。
量法:getComputedStyle 讀到的 box-shadow / outline 完全正常 —— 這條只能量像素。測試截圖後畫回 canvas 數黑點,並同時量上緣當對照組。
症狀:手把的 LT/RT 切換分頁,「macOS Chrome 可以,BOOX(Android e-ink)完全沒反應」。
成因:直覺會往 Android 的按鍵對應查 —— 但那兩顆鍵在兩邊都是 buttons[6]/[7]。真正的差別是螢幕寬度:窄螢幕一律進入 tabs 模式,而 cycleTab() 的判準還停在「≥md 而且 tabsMode」,於是 <md 落到 else 去切右欄自己的 tab —— 右欄在題目分頁下整欄 hidden,按下去畫面一個像素都不會動,跟按鍵沒送到長得一模一樣。BOOX 是 Android 平板,加上站上那顆「強制手機版面」FAB 會把視窗釘在 560px,必然落在窄的那一側。
修法:判準改成 tabsMode 本身。e2e 補一條 390×844 的案例 —— 這條路徑之前一次都沒被掃過,因為 gamepad.test.mjs 開頭寫著「接手把的人不會是在手機上」所以整支固定用 1280×900。那句話對 e-ink 平板不成立。
推論:測試檔開頭那些「因為 X 所以只測 Y」的註解,是會過期的假設。新裝置進場時,第一件事是回頭問那些假設還成不成立。
症狀:deploy.sh 每次部署都對 R2 與 Vectorize 誤報「may have failed」,但資源其實好好的。
成因:同一個症狀、兩個不同的成因,而且都是「解析 create 的輸出」這個做法的必然結果:
-
R2:
create在 bucket 已存在時 exit 1,而腳本開著pipefail,所以create | grep -q "already exists"整條 pipeline 回 1 —— grep 有沒有配到根本不影響結果,永遠走 else。 -
Vectorize:pipefail 那半已經處理過(捕進變數),但 wrangler 改了措辭,現在回
vectorize.index.duplicate_name,不再有already exists。
修法:先問存在性 —— r2 bucket info / vectorize get 只回 0/1,不必解析任何文字,兩種脆弱都繞開。順帶把失敗語意分開:R2 建不起來就讓 set -e 停下來(圖片上傳與 /img/* 都靠它),Vectorize 維持非致命(少了它相似題退回 BM25)。
推論:任何解析第三方 CLI 人類可讀輸出的判斷,都同時押注在「它的 exit code 語意」和「它的措辭」上。兩者都會變,而且變的時候是靜默的。
| 坑 | 說明 |
|---|---|
pnpm deploy |
撞到 pnpm 內建指令(ERR_PNPM_CANNOT_DEPLOY),要用 pnpm run deploy
|
| Pages 部署跑到 preview | 從 feature branch 部署會上 preview 環境;正式要 --branch=main
|
new URL(c.req.url).origin |
wrangler dev 下是 http:,會把壞掉的絕對 URL 烤進匯出檔 |
| 交卷時清掉標記 | 舊 submit() 會 sessionStorage.removeItem('exam-marks-…') —— 這才是「結果頁無法只看標記題」的真正根因,不是同步問題 |
| AI 模型 deprecated |
@cf/meta/llama-3.1-8b-instruct 下架後所有 AI 端點靜默失效;模型 ID 已集中在 worker/lib/ai-models.ts
|
D1 的 EXISTS() 回 0/1 |
不是 boolean,TypeScript 型別要放寬,否則編不過 |
| LSP 診斷可能是殘影 | 平行 worktree 被刪除後,編輯器仍會報大量「找不到模組」。以實際 tsc --noEmit / pnpm build 為準 |
| 新依賴後 build 假失敗 | 合併帶進新套件(如 vite-plugin-pwa)後,主工作區沒跑 pnpm install 會型別失敗,不是程式碼問題 |
.env 用舊名 CF_API_TOKEN
|
deploy.sh 掛在 Failed to fetch auth token: 400。不是 token 過期 —— wrangler 只認 CLOUDFLARE_API_TOKEN,要 export CLOUDFLARE_API_TOKEN="$CF_API_TOKEN"
|
| worktree 建出的 bundle 雜湊不同 | worktree 與主工作區的 node_modules 解析版本可能不同,同一份原始碼會建出不同檔名。要驗證上線行為,就驗 deploy.sh 實際產出的那份 dist
|
wrangler r2 object put --local 併發 |
多個 wrangler process 同時寫本機 R2 會撞 workerd 的 SQLite 鎖(database is locked)。只有 --remote 能平行 |
| Pages 跨部署資產仍在 | 舊部署的 hashed asset 在新部署後仍回 200。所以白屏不會是「舊 shell 抓不到新 chunk」—— 這條可以用來快速排除 stale shell 假設 |
| worktree 部署到 preview |
wrangler pages deploy 從 branch name 推環境,worktree 永遠不在 main 上。結果是「新 Worker + 舊前端」,看起來像快取問題。用 wrangler pages deployment list 確認頂列是 Production │ main
|
:where() 內部的逗號不是分隔符 |
稽核選擇器的工具用 split(',') 切 :is(:focus, :focus-visible),回報了三條不存在的違規,差點去「修」一條本來就正確的規則。要在括號深度 0 才切 |
outline-none 不是 outline-style: none
|
Tailwind 的 outline-none 是留給 focus ring 用的 2px 透明外框。任何「把 outline 塗上顏色」的通則都會讓它憑空長出實心黑框 —— 全站 30 處 focus:outline-none 一 focus 各多一個 |
| Xbox 360 的 D-pad 同時走軸 | hat switch 型 driver 把 D-pad 同時回報成 buttons[12–15] 和一組軸值。軸的捲動蓋過按鈕的走訪,症狀是「↑↓ 變成 scroll」「A 鍵沒反應」,看起來像三個無關的 bug,其實是一條因果鏈 |
| 偵測工具的偽陽性不該拿來改資料 | 題庫「選項截斷」的 7 個候選逐一查證後全是偽陽性(PDF 頁首、下一題題號、選項太短導致 find() 命中別題)。照著套只會把正確的選項改壞 —— 修的是工具,不是資料 |
| 索引的新鮮度不該決定頁面有沒有內容 | 弱點地圖整頁空白,不是門檻問題:Vectorize 回填停在某天,之後加入的年份一個向量都沒有。要有一條不依賴索引的確定性保底路徑 |
| PR 合併後,另一個 PR 的 mergeable 要等重算 | 連續合併時 GitHub 會先回 UNKNOWN,十幾秒後才變 CLEAN。看到 UNKNOWN 就當成衝突會誤判 |
- 技術債 Tech-Debt —— 已知但暫時不還的債
- 無伺服器的代價 —— 第一、七至十二則的共同成因,以及對策
- Head First 架構觀 —— 這些坑對應的架構決策
- 維運手冊 Maintenance —— 部署與除錯指令