react-markdown 的 GFM 陷阱:為什麼 h3 和 table 會消失
react-markdown 用錯 export,GFM 元素在 re-render 後靜悄悄消失。記錄診斷過程和三行修正。
在 personal-site 的文章詳細頁上,我用 react-markdown 渲染文章內文。文章裡有 h3 標題、粗體、table,看起來沒問題——直到我加了 Firestore fetch 之後。
頁面載入後,Firestore 回傳資料、更新 state,然後 h3、**粗體**、表格、--- 分隔線全部消失。h2 還在。完全沒有 console error,TypeScript 也沒報錯。
這是我遇到的兩個陷阱,以及它們為什麼同時藏在一起。
第一個坑:沒裝 remark-gfm
react-markdown 預設只處理 CommonMark 規格。表格(pipe syntax | col | col |)、刪除線、task list、autolink 這些屬於 GFM(GitHub Flavored Markdown) 擴充語法,預設不支援。
如果沒裝 remark-gfm,表格不會報錯,只是直接渲染成純文字:
| 名稱 | 說明 |
|------|------|
| LCP | 最大內容渲染 |
會變成一行奇怪的文字,不是 <table>。
修法很直接:
npm install remark-gfm
光這樣還不夠——第二個坑才是真正的問題。
第二個坑:MarkdownHooks + inline array
react-markdown 有兩個 export:
| Export | 行為 |
|---|---|
Markdown(default export) | 同步渲染 |
{ MarkdownHooks } | 非同步渲染,內部使用 useEffect |
我用的是 MarkdownHooks,而且 remarkPlugins 是這樣傳的:
// 問題寫法
<MarkdownHooks remarkPlugins={[remarkGfm]}>{body}</MarkdownHooks>
[remarkGfm] 是 inline array literal。每次 component re-render,這個 array 都是全新的 reference。
MarkdownHooks 內部的 useEffect 依賴這個 array reference 決定要不要重新執行 async processor。新 reference → 每次 re-render 都重跑 → 非同步處理的結果在 timing gap 裡遺失。
結果:Firestore fetch 回來更新 state → component re-render → useEffect 重跑 → GFM 元素在非同步間隙消失。
為什麼 h2 還在
這是最讓人困惑的地方:h2 正常,h3 不見。
MarkdownHooks 在非同步 pass 之前有一個初始同步 pass,部分處理了 ATX heading(# 語法)。h2 剛好在這個初始 pass 裡被正確渲染,所以看起來沒問題。
h3 在 GFM pass 才被完整處理,timing gap 發生後就不見了。
這讓問題看起來像「只有 h3 有 bug」,而不是「整個 async processor 在重跑」。
正確用法
三行修正:
import Markdown from "react-markdown"; // sync default export,不是 MarkdownHooks
import remarkGfm from "remark-gfm";
<Markdown remarkPlugins={[remarkGfm]}>{body}</Markdown>
同步 Markdown 不依賴 useEffect,inline array 不會造成問題。
什麼時候才用 MarkdownHooks
MarkdownHooks 的存在是有理由的:支援需要非同步處理的 remark/rehype plugin(例如 syntax highlighting 需要非同步 fetch 語言包)。
如果確實需要它,remarkPlugins 要用 useMemo 穩定 reference:
const plugins = useMemo(() => [remarkGfm], []);
<MarkdownHooks remarkPlugins={plugins}>{body}</MarkdownHooks>
少了 useMemo,每次 re-render 都會觸發 useEffect,回到同樣問題。
驗收方式
修完之後,用 Playwright 確認 GFM 元素有出現:
expect(await page.locator('.prose h3').count()).toBeGreaterThan(0);
expect(await page.locator('.prose strong').count()).toBeGreaterThan(0);
expect(await page.locator('table').count()).toBeGreaterThan(0);
這三個 locator 同時通過,代表 GFM 渲染和 re-render 行為都正確。
總結
| 問題 | 原因 | 修正 |
|---|---|---|
| 表格渲染成純文字 | 沒裝 remark-gfm | npm install remark-gfm + remarkPlugins={[remarkGfm]} |
| re-render 後 GFM 元素消失 | MarkdownHooks + inline array 每次產生新 reference | 換成 sync Markdown default export |
| 只有 h3 不見,h2 正常 | 初始同步 pass 部分處理 h2,讓問題看起來像 plugin 問題 | 同上,根本原因是 MarkdownHooks timing 問題 |