新しいAstroサイトをTailwind v4 + @fontsourceで立ち上げて、ビルド成果物をDevToolsのNetworkタブで眺めていたら、想定外の挙動を見つけました。日本語フォントのwoff2ファイルが全部404で返っている。
GET https://example.com/_astro/files/noto-sans-jp-0-400-normal.woff2
net::ERR_ABORTED 404 (Not Found)
GET https://example.com/_astro/files/noto-serif-jp-112-wght-normal.woff2
net::ERR_ABORTED 404 (Not Found)
500件近いリクエストが全部404。それなのにLighthouseは93点を取れていて、Playwrightも56/56 pass。ブラウザはHiraginoに静かにフォールバックして表示するので、視覚チェックだけでは見抜きづらい。Tailwind v4 + @fontsourceで新規プロジェクトを始めるなら必ず踏むタイプの落とし穴です。
症状の出方
dist/_astro/を覗くと、状況がはっきりします。
$ find dist/_astro -name "*.woff2" | wc -l
0
$ grep -oE "url\([^)]+\.woff2[^)]*\)" dist/_astro/BaseLayout.css | wc -l
496
ビルド済みCSSには496件のurl(./files/noto-...-normal.woff2)が並ぶ。けれど対応するwoff2ファイルがdist/_astro/files/に1つもない。dist/_astro/files/ディレクトリ自体が生成されていません。
node_modulesにはフォントが入っているか確認:
$ find node_modules/@fontsource node_modules/@fontsource-variable -name "*.woff2" | wc -l
1249
1249件入っている。けれどビルド時にdistへコピーされていない。
原因: Tailwind v4のlightningcssが@importを先に展開する
src/styles/global.cssがこういう構造の時に発生します。
@import "@fontsource/noto-sans-jp/400.css";
@import "@fontsource/noto-sans-jp/500.css";
@import "@fontsource/noto-sans-jp/700.css";
@import "@fontsource-variable/noto-serif-jp/wght.css";
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
/* ... */
}
@import "tailwindcss"が同居しているため、@tailwindcss/viteプラグインがlightningcssで全部の@importを一括処理します。そのlightningcssが@fontsourceの@importを展開する時、font-faceのurl(./files/...)をViteのasset resolverに通さないままCSS出力に書き込みます。
結果:
- ビルド済みCSSには
url(./files/noto-sans-jp-0-400-normal.woff2)がそのまま残る - Viteは
./files/...が何を指すか知らないので、asset処理をスキップ dist/_astro/files/が生成されない- ブラウザはCSSが指すパスを取りに行く → 404
通常Viteはurl()参照を解析してassetをコピーし、ハッシュ付きパスに書き換えます。url(/_astro/noto-sans-jp-0-400-normal.HASH.woff2)のような形に。Tailwind v4のlightningcssが先に処理してしまうと、この書き換えが起きません。
修正: @fontsource importを別ファイルに分離
修正は機械的です。@fontsourceの@importをglobal.cssから分離して別ファイルに置く。
src/styles/fonts.cssを新設:
@import "@fontsource/noto-sans-jp/400.css";
@import "@fontsource/noto-sans-jp/500.css";
@import "@fontsource/noto-sans-jp/700.css";
@import "@fontsource-variable/noto-serif-jp/wght.css";
src/styles/global.cssから該当行を削除。
BaseLayout.astroでglobal.cssの前にfonts.cssをimport:
---
import '@styles/fonts.css';
import '@styles/global.css';
---
これでビルドし直すと:
$ find dist/_astro -name "*.woff2" | wc -l
496
$ grep -oE "url\(/_astro/[^)]+\.woff2\)" dist/_astro/*.css | head -3
url(/_astro/noto-sans-jp-0-400-normal.CQM38w3s.woff2)
url(/_astro/noto-sans-jp-1-400-normal.BxmNQsBN.woff2)
url(/_astro/noto-sans-jp-2-400-normal.2pr-wf2b.woff2)
496件全部のwoff2がハッシュ付きでdistに出力。CSSのurl()参照も/_astro/<name>.<hash>.woff2に正しく書き換わっています。
fonts.cssにはTailwindの@importが含まれないので、Viteの標準CSS処理パイプラインが正常に動く。lightningcssが介在しません。
このパターンが見逃されやすい理由
自動テストやスコア計測では、このバグはまず捕捉できません。仕組み上どうしても見逃しやすい。
ブラウザの優しいフォールバック。CSSでfont-family: "Noto Sans JP", "Hiragino Kaku Gothic ProN", ...と指定していると、Noto Sans JPがロードできない時にHiraginoが使われます。Hiraginoは美しい日本語フォントなので、見た目の劣化が小さい。
Lighthouseはフォントロードを直接評価しない。LCPやTBTには影響が出ますが、woff2が404でもフォールバックフォントで描画完了は早いので、むしろスコア的には良くなる可能性すらあります。
Playwrightはフォント実体を検証しない。e2eテストは要素の存在、テキスト内容、aria属性などを見ますが、「このフォントファミリーが実際にロードされたか」までは検証しないのが普通。
CSSの404はConsoleに目立たない。<link>タグや<script>タグの404はConsoleにエラーとして出ますが、CSS内のurl()参照の404は警告として埋もれるかNetworkタブだけに出ます。
これらが重なるので、コードレビューと自動テストの組み合わせでは捕捉不能。発見には実機ブラウザのNetworkタブを直接覗く必要があります。
リリース前の確認手順
Tailwind v4 + @fontsourceの組み合わせで新規プロジェクトを立ち上げる時、リリース前に下記をやります。
npm run build後にfind dist -name "*.woff2" | wc -lでwoff2のコピー数を確認- 0件ならこの落とし穴に該当、
fonts.css分離を実施 - デプロイ後、ブラウザでサイトを開きDevToolsのNetworkタブをタイプ
Fontでフィルタ - 全woff2が200で返っているか確認、404があれば実装側の問題
特に3は10秒で済む確認なので、本番反映前の習慣にすると、フォント以外のサイレントな404も同時に拾えます。
教訓: 自動テストが緑でも本番は見る
「自動テストが全部通っているからOK」では検出できない種類のバグがあります。
- フォントが配信されていなくてもフォールバックで動く
- スクリプトが配信されていなくてもtry/catchで握り潰される
- 画像が配信されていなくてもaltテキストで成立する
- API呼び出しが失敗してもデフォルト値で動く
これらは「サイレントな劣化」で、テストは通るのにユーザー体験が壊れる類のバグ。リリース前に手作業でブラウザを開いてDevToolsのNetworkタブを眺める、というローテクな確認が、自動テストの盲点を埋めます。
Tailwind v4と@fontsourceは、素直に@import同居させると壊れます。@fontsourceの@importは別ファイルに分離して、Viteの標準pipelineに通すのが正解。Tailwind v4で新規Astroサイトを立ち上げる時のチェックリストに「distにwoff2が出ているか確認」を入れておくと、本番反映前に確実に拾えます。