我加了一道守衛防瀏海,結果我自己的修法把它變成了瞎子
47px。這是有瀏海的 iPhone 回給 env(safe-area-inset-top) 的值,也是這週壓在一顆「取消刪除」按鈕上的狀態列高度。
帳號進入刪除倒數之後,畫面最上面會出現一條橫幅,上面那顆按鈕是使用者把資料救回來的唯一入口。整條橫幅被狀態列蓋在底下,時鐘正好壓在按鈕上——你看得到「取消刪除」四個字的下半截,但你按不到它。
修它是一行 CSS 的事。真正的故事是我為了防止它再發生而寫的那道測試守衛:兩天後它漏掉一個位置,而我補完之後,覆核的人告訴我,我的補法讓它從此什麼都看不見。
先講前情:誰該付那段 inset
這個站原本的慣例是「頁面自己的 header 吃掉 inset」。問題出在那條橫幅是由 dashboard layout 注入在 {children} 之上的。它一出現,它才是螢幕最頂端的元素,但它沒有繼承那個慣例,結果 inset 被下面的 header 付掉了,橫幅自己貼著螢幕頂端。同一個位置上的「殼層有新版本」通知也一樣。
規則收斂成一句話:螢幕上最頂端的那個元素付 inset,它之後的一切從零開始。 寫成 CSS 只有兩條:
.shell-top-strip {
padding-top: max(var(--safe-top), 0.75rem);
}
.shell-top-strip ~ * {
--safe-top: 0px;
}
關鍵在 ~ * 那條。CSS custom property 會繼承,所以這一條同時蓋掉「第二條 strip」跟 {children} 裡的頁面 header——inset 只付一次,不會疊出兩段留白。頁面 header 全部改讀 var(--safe-top),不再直接呼叫 env()。
眼睛看不到的東西,只能用測試守
這種 bug 在瀏覽器裡重現不出來,網址列會幫你把那 47px 擋掉。要看到它得進 Capacitor 的 iOS 原生殼,而原生殼不會進 CI。所以如果你的 app 有原生殼,這一層基本上是沒有人在看的。
規則本身只好改用測試守。做法是把 globals.css 裡那條 selector 讀出來,不是在測試裡重抄一遍——重抄的話 CSS 改個名字它不會壞,那就不叫守衛了。測試裡只留一個判定函式:
const COLLAPSE_SELECTOR = '.shell-top-strip ~ *'
function paysInset(el: Element): boolean {
return el.closest(COLLAPSE_SELECTOR) === null
}
拿它對真實 render 的 DOM 跑「兩條 strip 都在/只有橫幅/只有通知/都沒有」四種組合,每一種都檢查 inset 恰好被付一次。外加一條白名單:除了那幾個 sticky header,其他 dashboard header 都不准偷偷把 env() 加回來。
寫完覺得滿完整的。CI 綠,規則被鎖住了,下次誰動 CSS 都會被擋。
然後另一條提示條出事了
兩天後,另一個位置炸了:顯示「你正在看過去章節」的那條提示是 sticky top-0,整條大約 35px,而瀏海 inset 是 47px。使用者一往下捲,它就 pin 進狀態列裡,連同「離開過去章節」的出口一起消失在時鐘後面。
那道守衛完全沒反應。
原因很直接。白名單是以「誰呼叫了 env()」為軸建的,它只抓得到「多付了 inset」的 header。提示條從來沒呼叫過 env(),所以它一輩子都在守衛的視線外。守衛只看得到它問過的那個問題。
修那三個 sticky header 的時候撿到一個細節。其中兩個原本寫死 pt-12,也就是 48px,剛好比 iPhone 的 47px 多 1px,所以在現有機種上看起來完全正常——那是巧合,不是設計(我當初寫 pt-12 的時候根本不知道有這回事)。改成 pt-[max(env(safe-area-inset-top),48px)] 之後,換一個 inset 更深的機種也不會退化。
誤診:把掃描方向反過來就好了吧
我的結論是守衛要從版面位置出發,不是從 API 呼叫出發。每一個 sticky top-0 / fixed top-0,要嘛處理 inset,要嘛列在白名單並寫明理由。
聽起來很對吧?我當時也這麼覺得。
真相:我的修法把五個檔案變成了盲區
覆核的時候被指出一件事:兩個 filter 都是對整個檔案字串發問的——「這個檔案裡有沒有 sticky top-0」、「這個檔案裡有沒有 env(safe-area-inset-top)」。
而我上一步的修法正好是「每個會 pin 的檔案都加上 env()」。
也就是說,那五個檔案在 merge 之後全部拿到通行證。之後不管再長出什麼 pinned 元素,都會直接過關。RecordsList.tsx 最危險:300 多行、已經有一個 sticky L1 header,下一個進來的直接繼承。
我當下沒有修,只補了一段註解把這件事記下來。不是偷懶——它不是一行改得完的。RecordsList 的 sticky wrapper 自己不付 inset,inset 是由它裡面的子元素付的,而這是乾淨寫法:wrapper 負責 pin 跟鋪底色,底色要能一路鋪到瀏海底下;子元素負責 padding,把內容推出瀏海。如果改成逐字串檢查,這個正確的形狀會被報成「未處理」。我做了一個 prototype 去驗,它確實就卡在那個 wrapper 上。
所以真正需要的是第二個豁免類別,不是把 regex 寫得更嚴。
逐元素判定,跟兩類豁免
改完之後,掃描的單位從「一個檔案」變成「一個被釘住的元素」,只看那一串 class 有沒有付 inset。於是「沒付 inset 但沒錯」分成兩類,每一類都要自己寫理由:一類是它會 pin 但永遠碰不到狀態列,因為它的 containing block 是某個內層 scroller,top-0 指的是那個盒子的上緣;另一類是它碰得到狀態列,但 inset 由裡面的子元素付。
抓 class 字串那段我踩了一個坑,值得單獨講。直覺寫法是拿一段 regex 去掃整個檔案、把引號兩兩配對。不要這樣做。 程式碼裡任何一個單獨的 apostrophe——註解裡的 don't 就夠了——會讓它後面所有配對錯開一位。結果不是噴錯,是從那一行之後整個檔案靜默漏掉。改成從 regex 命中的位置往前後擴展到最近的引號,就不依賴前文了。
另外加了一條看起來很多餘的測試:
it('finds the pinned elements at all', () => {
expect(pinned.length).toBeGreaterThanOrEqual(EXEMPTIONS.length + 1)
})
它只是確認掃描器至少找得到東西。沒有這條的話,哪天 regex 被改壞了,底下每一個 assertion 都會拿到空集合、然後全部通過,整份測試檔會變成綠燈的裝飾品。同樣道理還加了一條防呆:豁免項如果已經對不上任何實際元素就報紅,逼人回來更新或刪掉。一份沒人會再讀的白名單,遲早會蓋住真的問題。
收尾
這種只在原生殼重現的版面 bug,守衛要從版面位置出發(誰會被釘在最上面),不要從 API 呼叫出發(誰呼叫了 env())。後者只抓得到你已經想到的那一半。
什麼情況下我會改變主意——如果你的 app 只跑瀏覽器、沒有原生殼,這整篇可以跳過,網址列會幫你擋掉全部。
還沒解的有兩件。提示條捲到頂的時候 inset 會被付兩次,因為它上面永遠有另一個 header,深色條會多長 37px。把殼層 strip 改成常駐置頂可以一次解掉,但 sticky 元素之間不會互相讓位,那條路要去量 strip 的動態高度,我還沒想好怎麼寫才不會更糟。
另一個是理論上的:如果之後引入 clsx 之類的 class helper,clsx('sticky', 'top-0') 這種寫法 regex 抓不到,因為它跨了兩個字串。目前這個 repo 沒用任何 class helper,所以不痛。我把它寫進 regex 的註解裡了——這種洞的失敗模式是靜默,它不會自己開口說話。
先不說了,我得去看看那五個檔案裡,這兩天有沒有誰又偷偷長出新的 sticky header。
這段 code 寫於 2026 年 9 月 12 日,文章整理於同一個晚上。