[{"data":1,"prerenderedAt":604},["ShallowReactive",2],{"content-/blog-payload-null-root-fix":3,"all-pages-for-dir":602,"og-image-/blog-payload-null-root-fix":603},{"id":4,"title":5,"body":6,"category":581,"description":582,"extension":583,"meta":584,"navigation":585,"path":586,"project_name":587,"published":588,"publishedAt":589,"seo":590,"stem":591,"tags":592,"todo":600,"unpublished":588,"updatedAt":600,"__hash__":601},"pages/2026-06/2026-06-05/blog-payload-null-root-fix.md","/blog のカレンダーが一瞬光って消える hydration mismatch を payload null 化で突き止めた",{"type":7,"value":8,"toc":570},"minimark",[9,18,21,25,31,41,44,47,54,57,78,81,99,125,129,143,157,160,164,167,197,206,209,407,423,426,443,470,473,480,510,520,523,529,539,542,566],[10,11,12,13,17],"p",{},"朝、ベッドの上で iPhone から ",[14,15,16],"code",{},"/blog"," を開いたら、6月のカレンダーが一瞬だけ視界に入って、まばたきの間に5月に切り替わった。記事リンクも27本から8本へ目減りした。前日のデプロイ後、何かをまた壊した気配がした。",[10,19,20],{},"積み残しの確認から始めたら、昨日中途半端に終わっていた blog-hydration-mismatch の計画書が真っ先に目に入った。「これお願いします」と Claude Code に投げた。",[22,23,24],"h2",{"id":24},"症状の最初の手触り",[10,26,27,28,30],{},"ブラウザで ",[14,29,16],{}," を開く。一瞬だけ「2026年 6月」のカレンダーが見え、記事リンクが画面いっぱいに並ぶ。その直後、画面全体が点滅して「2026年 5月」に置き換わる。記事は8本まで減る。",[10,32,33,36,37,40],{},[14,34,35],{},"view-source:"," で SSR の HTML を覗くと、ちゃんと6月のカレンダーと27本の記事リンクが含まれている。",[14,38,39],{},"curl"," でも同じ結果が返る。サーバが返す HTML は正しい。壊れているのは hydration 後の DOM だけだった。",[10,42,43],{},"DevTools の Console には例の「Hydration completed but contains mismatches.」のエラーが出ていた。",[22,45,46],{"id":46},"切り分けの順序",[10,48,49,50,53],{},"dist の ",[14,51,52],{},"sql_dump.txt"," を疑った（過去にここが配信失敗で SQLite 初期化が壊れた事例があった）。本番の HTTP ステータスを叩いたら 200 OK で 7.5MB が返ってきた。配信路は健全。",[10,55,56],{},"WASM 初期化失敗を疑った。Console を見たが SQLite 関連のエラーは一切出ていない。これも違う。",[10,58,59,60,63,64,67,68,74,75,77],{},"次に ",[14,61,62],{},"_payload.json"," を直接 fetch して中身を覗いた。",[14,65,66],{},"blog-public-articles"," の slot が ",[69,70,71],"strong",{},[14,72,73],{},"null"," になっていた。SSR は配列を取れているのに、シリアライズ段階で ",[14,76,73],{}," に化けている。これが決定打だった。",[22,79,80],{"id":80},"根本原因",[10,82,83,86,87,90,91,94,95,98],{},[14,84,85],{},"apps/web/app/composables/useBlogArticles.ts"," で ",[14,88,89],{},"queryCollection('blog').select(...).all()"," の戻り値をそのまま ",[14,92,93],{},"useAsyncData"," の戻り値にしていた。見た目は普通の配列だが、内部に Vue の reactive proxy / ",[14,96,97],{},"@nuxt/content"," の内部 class instance / Symbol プロパティを抱えている。",[10,100,101,102,107,108,110,111,113,114,117,118,120,121,124],{},"Nuxt の payload reducer は、知らない class instance に出会うと ",[69,103,104,106],{},[14,105,73],{}," に落として書き出す","。結果として ",[14,109,62],{}," の該当 slot が ",[14,112,73],{}," になり、CSR で ",[14,115,116],{},"data.value"," が ",[14,119,73],{}," になり、",[14,122,123],{},"null || []"," のフォールバックで空配列にすり替わる。カレンダーは「今月の記事ゼロ」と判定して前月にフォールバックする。これがあの「一瞬出てから消える」の正体だった。",[22,126,128],{"id":127},"dev-で再現しなかった罠","dev で再現しなかった罠",[10,130,131,132,135,136,138,139,142],{},"このバグの嫌らしさは、",[69,133,134],{},"dev で完全に再現しない","ことだった。",[14,137,93],{}," に ",[14,140,141],{},"getCachedData: getHydrationCachedData"," が刺さっていて、dev では SSR の値をそのままハイドレーション側のキャッシュに渡している。payload を経由しないので null 化も起きない。",[10,144,145,148,149,152,153,156],{},[14,146,147],{},"pnpm dev"," でいくら触っても問題は出ない。",[14,150,151],{},"pnpm generate"," してビルドした dist を ",[14,154,155],{},"wrangler pages dev dist"," で配信した瞬間に再現する。本番 (Cloudflare Pages Static) でも同じ。",[10,158,159],{},"「dev で動いてるから本番も動く」が嘘になる典型例だった。SSG + Static 配信は payload 経路を通る、という当たり前を体で覚え直した。",[22,161,163],{"id":162},"d-案-plain-pojo-化を選んだ理由","D 案: plain POJO 化を選んだ理由",[10,165,166],{},"対策は2つ候補が出た。",[168,169,170,184],"ul",{},[171,172,173,180,181,183],"li",{},[69,174,175,176,179],{},"A 案: ",[14,177,178],{},"useState"," 切替",": payload に乗せず、",[14,182,178],{}," で状態管理する。mode 別 (public/unpublished) の state 分離が必要で、構造を作り変える話になる",[171,185,186,189,190,192,193,196],{},[69,187,188],{},"D 案: plain POJO 化",": ",[14,191,93],{}," のハンドラ末尾で ",[14,194,195],{},".map"," して、フィールドを明示列挙したプレーンオブジェクトに詰め替える。class instance / reactive proxy を引き剥がす",[10,198,199,200,202,203,205],{},"A 案は構造が太る。しかも ",[14,201,178],{}," も結局 payload に乗るので、根本原因（class instance を return している）は同じ。D 案は12行追加するだけで原因を直接断てる。",[14,204,93],{}," の API もそのまま使える。即 D 案を選んだ。",[10,207,208],{},"要点だけ書くと、こういう形に詰め替える。",[210,211,216],"pre",{"className":212,"code":213,"language":214,"meta":215,"style":215},"language-ts shiki shiki-themes vitesse-light vitesse-light","return filtered.map(item => ({\n  title: item.title,\n  description: item.description,\n  path: item.path,\n  tags: Array.isArray(item.tags) ? [...item.tags] : item.tags,\n  publishedAt: item.publishedAt,\n  updatedAt: item.updatedAt,\n  // 必要なフィールドだけ列挙する。`{...item}` だと隠れ proxy が残るので NG\n}))\n","ts","",[14,217,218,251,270,287,304,360,377,394,401],{"__ignoreMap":215},[219,220,223,227,231,235,239,242,245,248],"span",{"class":221,"line":222},"line",1,[219,224,226],{"class":225},"sHkkW","return",[219,228,230],{"class":229},"s4oTP"," filtered",[219,232,234],{"class":233},"shFtX",".",[219,236,238],{"class":237},"senZ8","map",[219,240,241],{"class":233},"(",[219,243,244],{"class":229},"item",[219,246,247],{"class":233}," =>",[219,249,250],{"class":233}," ({\n",[219,252,254,258,260,262,264,267],{"class":221,"line":253},2,[219,255,257],{"class":256},"sz8Xr","  title",[219,259,189],{"class":233},[219,261,244],{"class":229},[219,263,234],{"class":233},[219,265,266],{"class":229},"title",[219,268,269],{"class":233},",\n",[219,271,273,276,278,280,282,285],{"class":221,"line":272},3,[219,274,275],{"class":256},"  description",[219,277,189],{"class":233},[219,279,244],{"class":229},[219,281,234],{"class":233},[219,283,284],{"class":229},"description",[219,286,269],{"class":233},[219,288,290,293,295,297,299,302],{"class":221,"line":289},4,[219,291,292],{"class":256},"  path",[219,294,189],{"class":233},[219,296,244],{"class":229},[219,298,234],{"class":233},[219,300,301],{"class":229},"path",[219,303,269],{"class":233},[219,305,307,310,312,315,317,320,322,324,326,329,332,336,339,341,343,345,348,351,354,356,358],{"class":221,"line":306},5,[219,308,309],{"class":256},"  tags",[219,311,189],{"class":233},[219,313,314],{"class":229},"Array",[219,316,234],{"class":233},[219,318,319],{"class":237},"isArray",[219,321,241],{"class":233},[219,323,244],{"class":229},[219,325,234],{"class":233},[219,327,328],{"class":229},"tags",[219,330,331],{"class":233},") ",[219,333,335],{"class":334},"stQ0i","?",[219,337,338],{"class":233}," [...",[219,340,244],{"class":229},[219,342,234],{"class":233},[219,344,328],{"class":229},[219,346,347],{"class":233},"] ",[219,349,350],{"class":334},":",[219,352,353],{"class":229}," item",[219,355,234],{"class":233},[219,357,328],{"class":229},[219,359,269],{"class":233},[219,361,363,366,368,370,372,375],{"class":221,"line":362},6,[219,364,365],{"class":256},"  publishedAt",[219,367,189],{"class":233},[219,369,244],{"class":229},[219,371,234],{"class":233},[219,373,374],{"class":229},"publishedAt",[219,376,269],{"class":233},[219,378,380,383,385,387,389,392],{"class":221,"line":379},7,[219,381,382],{"class":256},"  updatedAt",[219,384,189],{"class":233},[219,386,244],{"class":229},[219,388,234],{"class":233},[219,390,391],{"class":229},"updatedAt",[219,393,269],{"class":233},[219,395,397],{"class":221,"line":396},8,[219,398,400],{"class":399},"sxvE3","  // 必要なフィールドだけ列挙する。`{...item}` だと隠れ proxy が残るので NG\n",[219,402,404],{"class":221,"line":403},9,[219,405,406],{"class":233},"}))\n",[10,408,409,412,413,416,417,422],{},[14,410,411],{},"{...item}"," の shallow copy では Symbol プロパティと reactive proxy がそのまま残るので意味がない。",[14,414,415],{},"JSON.parse(JSON.stringify())"," は動くが型情報が壊れる。",[69,418,419,421],{},[14,420,195],{}," でフィールドを1つずつ列挙する"," のが一番安全だった。",[22,424,425],{"id":425},"検証",[10,427,428,429,432,433,435,436,438,439,442],{},"ローカルで ",[14,430,431],{},"pnpm test:run"," を走らせて 8/8 pass。",[14,434,151],{}," でビルドして ",[14,437,155],{}," で本番相当を立てた。",[14,440,441],{},"diagBlog()"," のスニペットを Console で叩く。",[10,444,445,117,448,450,451,454,455,458,459,86,462,465,466,469],{},[14,446,447],{},"payload slot 3",[14,449,73],{}," から ",[69,452,453],{},"長さ1149の配列"," に変わった。",[14,456,457],{},"fromDom.currentMonth"," が「2026年 6月」になり、記事リンクが41本表示された。本番デプロイ後にも同じスクリプトを叩いて、",[14,460,461],{},"log.eurekapu.com/blog",[14,463,464],{},"fromServer"," と ",[14,467,468],{},"fromDom"," が一致するのを確認した。",[22,471,472],{"id":472},"再発防止ルールを固定",[10,474,475,476,479],{},"このバグはこの半年で2回目だった。前回も同じ罠で半日溶かしている。同じことを3回やるのは耐えがたいので、",[14,477,478],{},".claude/rules/nuxt-content-payload-null.md"," に恒久ルールとして書き込んだ。",[168,481,482,485,491,494,500],{},[171,483,484],{},"症状（一瞬出てから消える / payload slot が null）",[171,486,487,488,490],{},"検出方法（DevTools Console で叩く ",[14,489,441],{}," スニペット）",[171,492,493],{},"対策（D 案 plain POJO 化のテンプレ）",[171,495,496,497,499],{},"再発防止チェックリスト（",[14,498,93],{}," で外部由来の class instance を返していないか）",[171,501,502,503,505,506,509],{},"やってはいけないこと（",[14,504,411],{}," shallow copy / ",[14,507,508],{},"any"," で型を潰す）",[10,511,512,513,465,516,519],{},"モノレポルートの ",[14,514,515],{},"CLAUDE.md",[14,517,518],{},"apps/web/CLAUDE.md"," にもポインタを貼った。次に同じ症状を見たら、Claude Code がツリーを遡る過程でこのルールに当たるはずだ。",[22,521,522],{"id":522},"学び",[10,524,525,526,528],{},"「dev で動いてるから本番も動く」を信じない。SSG + Cloudflare Pages Static は payload 経路を通るので、デプロイ後にブラウザの DevTools Console で ",[14,527,62],{}," の該当 slot まで目で確認する。ここを省くと、SSR の HTML が正しい分だけ問題に気づくのが遅れる。",[10,530,531,532,535,536,538],{},"そして ",[14,533,534],{},"queryCollection"," の戻り値を payload に乗せるときは、必ず ",[14,537,195],{}," で詰め替える。フィールドを1つずつ列挙する。手間は12行で済む。代わりに、半年後にまた半日溶かす未来を1つ消せる。",[22,540,541],{"id":541},"関連",[168,543,544,549,554,560],{},[171,545,546,548],{},[14,547,478],{},"（再発防止ルール）",[171,550,551,553],{},[14,552,85],{},"（修正実装）",[171,555,556,559],{},[14,557,558],{},"apps/web/scripts/verify-blog-payload.mjs","（HTML 直接走査での検証スクリプト）",[171,561,562,565],{},[14,563,564],{},"memo/2026-06-04/blog-hydration-mismatch.md","（前日の調査ログ）",[567,568,569],"style",{},"html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .sz8Xr, html code.shiki .sz8Xr{--shiki-default:#998418;--shiki-dark:#998418}html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":215,"searchDepth":253,"depth":253,"links":571},[572,573,574,575,576,577,578,579,580],{"id":24,"depth":253,"text":24},{"id":46,"depth":253,"text":46},{"id":80,"depth":253,"text":80},{"id":127,"depth":253,"text":128},{"id":162,"depth":253,"text":163},{"id":425,"depth":253,"text":425},{"id":472,"depth":253,"text":472},{"id":522,"depth":253,"text":522},{"id":541,"depth":253,"text":541},"dev","Cloudflare Pages にデプロイした /blog で当月の記事27本が一切表示されない。SSR の HTML には載っているのに、ブラウザで見ると一瞬出てから空に化ける。原因は queryCollection の戻り値が payload reducer で null に落ちることだった。plain POJO に詰め替えて修復し、再発防止ルールを .claude/rules/ に固定した。","md",{},true,"/blog-payload-null-root-fix","mdx-playground",false,"2026-06-05T00:00:00.000Z",{"title":5,"description":582},"2026-06/2026-06-05/blog-payload-null-root-fix",[593,594,595,596,597,598,599],"nuxt","nuxt-content","ssg","hydration","cloudflare-pages","payload","trouble-shooting",null,"lzq-XOs1_Dm0m4FGvw2X4Nayg4d-aSi_8Py6o4OCdfQ",[],"https://log.eurekapu.com/og/blog/blog-payload-null-root-fix.png?v=2026-06-05T00%3A00%3A00.000Z&title=%2Fblog%20%E3%81%AE%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%8C%E4%B8%80%E7%9E%AC%E5%85%89%E3%81%A3%E3%81%A6%E6%B6%88%E3%81%88%E3%82%8B%20hydration%20mismatch%20%E3%82%92%20payload%20null%20%E5%8C%96%E3%81%A7%E7%AA%81%E3%81%8D%E6%AD%A2%E3%82%81%E3%81%9F&author=Kei%20Komatsu&sig=eae2aaf90aecdf24",1780698983432]