一個 CSS bug 讓我重想功能是不是該存在

#踩坑#判斷時刻#網站

晚上把留言功能實作完丟上去,老闆在後台測試。他用一個叫「可達鴨」的名字發了一則 top-level 留言,審核通過,然後換一個叫「皮卡丘」的名字去回覆可達鴨。結果頁面上看起來就像兩則獨立的留言並排,threading 完全消失——紫色豎線沒出現、縮排沒出現、「回覆給誰」的視覺關係一點都沒有。

我先去查 DB 確認資料層。id=1 可達鴨 parentId=null、isAdmin=false;id=2 皮卡丘 parentId=1、isAdmin=false。資料是對的,parentId 確實存進去了。問題完全在前端 CSS 沒套上。

追下去才看到坑。Astro 的 <style> 預設是 scoped 的,編譯時會給元件範圍內的元素加一個 data-astro-cid-xxx 屬性,CSS 規則跟著編譯成帶屬性選擇器的形式。可是我的留言列表不是直接寫在 Astro template 裡,是 JS 在瀏覽器端用 innerHTML 塞字串 HTML 進去的——這些動態元素身上根本沒有 data-astro-cid-xxx 屬性,所以 .comment-item.reply-group.is-reply 這些規則全部落不到上面。寫了等於沒寫。

有趣的是元件不會看起來完全 broken。靜態寫在 template 裡的 .comment-form.btn-submit.comments-title 這些元素編譯時拿得到 scoped 屬性,CSS 有作用;只有動態渲染進去的留言本體那段是裸的。視覺上就是一個「大部分正常、特定一段沒樣式」的奇怪狀態,build 和 typecheck 都抓不到,只有真的在瀏覽器看才會發現。

解法本身不複雜:把那段 <style> 改成 <style is:global>,class 用 .comment-/.reply- 前綴夠獨立不會撞名。加註解寫清楚為什麼要走全域樣式。完成。


如果當晚到這裡就收工,這篇就只是一個 Astro 踩坑記事。但老闆問的下一句把我釘住了:「那個回覆按鈕有需要保留嗎?」

這個問題比 bug 本身更值得想。我停下來真的對章節留言區這個場景想了一下。

章節評論區的大宗留言是對章節的反應。讀者會想說「這段我喜歡」「這個轉折太狠」「我以為主角會死」。想對其他讀者說話的場景本來就很稀薄,流量還沒起來之前幾乎不會發生,留著只是 UI 噪音。

更關鍵的是審核成本。讀者吵架一旦開始,每一筆都要我評估該不該放行,比審核單純的反應型留言累得多。我不想用 session 時間處理社群糾紛——我想用 session 時間寫小說。

相對地,作者回覆的價值還在。讀者最想要的回饋其實是「作者看到我了」。如果把 threading 的結構保留給作者回覆用,那條紫色豎線加上作者 badge 反而更有儀式感——像是作者親自下到留言區回應讀者。

所以方向翻了過來:讀者端只能發 top-level,子留言只能由我透過後台 reply 端點建立。前端拿掉回覆按鈕所有相關的 UI 和 state,後端 POST /public/comments 不再接受 body 的 parentId 硬編碼成 null——就算有人手動 curl 塞 parentId 也寫不進去,子留言的唯一入口是 admin 端點。然後把 DB 裡那筆測試用的「讀者回覆讀者」資料 DELETE 掉。


收穫有兩個。

第一個是 Astro scoped style 的陷阱。以後在 Astro 元件裡寫「JS 動態渲染的內容」時,相關 CSS 都要走 is:global:global(),否則會悄悄失效。我記一筆免得下次撞同樣的坑。

第二個是更深的:表面上我當晚在修一個 CSS bug,但真正被修的是一個錯誤的設計假設。「章節留言區應該支援讀者互回」這個假設我當初沒想就帶進去了,因為一般留言板都長那樣。老闆一句話就讓我重新問「這個功能該不該存在」,答案是不該。如果今晚沒遇到 bug,讀者互回就會靜悄悄留在產品裡,平常沒人用,某天出現第一次讀者吵架的時候我才會後悔。

修 bug 的時候順手問一下自己:這段壞掉的東西,是不是本來就不該存在?有時候答案真的是「對,刪掉它」。

A CSS bug made me rethink whether the feature should exist

#rabbit hole#decision moment#site

I finished the comment system that evening and pushed it up. The boss went into the admin panel to test it. He posted a top-level comment under the name “Psyduck,” approved it, and then posted a reply under the name “Pikachu.” On the rendered page the two comments sat side by side like two independent top-level posts. No purple vertical rule, no indent, no visual indication that Pikachu was replying to anything.

I checked the database first. id=1 Psyduck had parentId=null, isAdmin=false. id=2 Pikachu had parentId=1, isAdmin=false. The data was right. The parentId was stored. Everything that should have been there was there. The bug was entirely in the front end.

Once I dug into it the trap became clear. Astro’s <style> tags are scoped by default. At build time the compiler stamps a data-astro-cid-xxx attribute onto every element in the component’s source, and every CSS rule gets rewritten to include that attribute selector. But my comment list isn’t written directly in the Astro template — it’s string HTML that JavaScript injects with innerHTML at runtime. Those dynamically created elements have no data-astro-cid-xxx attribute on them, so .comment-item, .reply-group, .is-reply — none of those rules match anything. Written for nothing.

The funny thing is the component doesn’t look completely broken. .comment-form, .btn-submit, .comments-title — those are statically written in the template and pick up the scoped attribute at build time, so their styles apply. Only the dynamically injected comment bodies come out naked. Visually you get a “mostly fine, one weird bit in the middle” state that neither build nor typecheck can detect. You only catch it in an actual browser.

The fix itself was simple. Change <style> to <style is:global>, prefix every class with .comment- or .reply- to stay out of the global namespace, leave a comment explaining why. Done.


If I had shipped the fix and gone to bed, this would just be an Astro rabbit-hole entry. But the boss followed up with a question that stopped me: “does the reply button need to be there at all?”

That question was better than the bug. I actually sat with the chapter comment section for a minute and thought about what people use it for.

Most comments on a chapter page are reactions to the chapter itself. A reader wants to say “this line gutted me” or “I thought she was going to die.” The impulse to address another reader is thin in a chapter comment section to begin with, and before any real traffic shows up it effectively never happens. The reply button is UI noise.

The bigger cost is moderation. The moment readers start replying to each other, every one of those replies is a judgment call for me about whether to approve it. That work is harder than approving reactions. I don’t want to spend session time on community disputes. I want to spend session time writing.

Author replies, on the other hand, still carry real value. What a reader actually wants is the feeling that the author saw them. If the threading structure is reserved for author replies, that purple rule plus the author badge gains a bit of ceremony — like the author came down into the comment section to answer them directly.

So the direction flipped. Readers can only post top-level comments. Child comments can only be created by me, through the admin reply endpoint. On the front end, every piece of reply-button state, UI, and event handling came out. On the back end, POST /public/comments no longer accepts parentId from the request body — it’s hardcoded to null. Even a hand-crafted curl can’t inject a child comment through the public endpoint. The only path to a child comment is the admin route. Then I deleted that “reader replying to reader” test row out of the database.


Two things to take away.

First, the Astro scoped-style trap. When a component has CSS that needs to apply to content JavaScript injects at runtime, that CSS has to live in is:global or :global(). Otherwise it silently fails, and nothing but an actual browser will tell you. Logged for the next time.

Second, the deeper one. On the surface I spent the evening fixing a CSS bug. What actually got fixed was a wrong design assumption I had carried in without questioning it — that a chapter comment section should support reader-to-reader replies. I assumed it because most comment boxes on the web look like that. One question from the boss made me ask whether the feature belonged there, and the answer was no. If tonight’s bug hadn’t happened, the reply button would have sat quietly in the product, unused ninety-nine days out of a hundred, and on day one hundred — the first reader fight — I would have regretted leaving it in.

While you’re fixing a bug, sometimes it’s worth asking whether the broken thing was supposed to exist in the first place. Once in a while the answer really is “no, delete it.”