本文へ移動
myowchick

Astroのclient:loadは変数のタグに効かない

ツール置き場をcontent collectionに寄せたとき、/tools/<slug>の1ページで15個のツールを出し分けようとした。slugでReactコンポーネントを引いてclient:loadを付ければ済む、と思っていた。

ビルドは落ちた。NoMatchingImport: Could not render Tool. No matching import has been found for Tool.。importはしているのに、importが見つからないと言われる。

Astroの動的なタグにはclient:*を付けられない。理屈が分かれば当たり前なんだけど、落ちる場所が思ったより後ろで、たどるのに時間がかかった。以下はAstro 7.2.2、@astrojs/compiler 2.13.1で実測したもの。

落ちるコード

これだけで再現する。

---
import { StringLengthTool } from '@/components/tools/StringLengthTool'
const frames = { 'string-length': StringLengthTool }
const Tool = frames['string-length']
---

<Tool client:load />

astro buildの出力。

[ERROR] NoMatchingImport: Could not render `Tool`. No matching import has been found for `Tool`.
  Hint:
    Please make sure the component is properly imported.

先に断っておくと、コンパイルは通る。落ちるのはページを描画する段、つまり静的ビルドならHTMLを書き出す瞬間だ。エラーがコンパイラからではなくランタイムから飛んでくるので、テンプレートの書き方が原因だとすぐには結びつかない。

なぜ落ちるか

@astrojs/compilerのtransformにそれぞれ食わせて、出てくるコードを見比べると一目で分かる。

静的なタグ(<Foo client:load />)はこうなる。

$$renderComponent($$result, 'Foo', Foo, {
  'client:load': true,
  'client:component-hydration': 'load',
  'client:component-path': '/Foo',
  'client:component-export': 'default',
})

変数に入れたタグ(<Tag client:load />)はこう。

$$renderComponent($$result, 'Tag', Tag, {
  'client:load': true,
  'client:component-hydration': 'load',
})

消えているのはclient:component-pathとclient:component-exportの2つ。これはブラウザ側が「どのJSファイルの、どのexportを起こせばいいか」を知るための情報で、島を立てるのに要る。AstroのランタイムはextractDirectivesでこの2つを拾い、componentExport.valueが空ならNoMatchingImportを投げる(astro/dist/runtime/server/hydration.js)。

つまりコンパイラは、テンプレートに書かれたタグ名とfrontmatterのimport文を突き合わせて、一致したものだけにパスとexport名を焼き込んでいる。変数のタグには突き合わせる相手がいないので、2つのpropが黙って抜ける。

突き合わせているのはタグ名で、値ではない

ここが一番意外だった。次のコードも落ちる。

---
import { StringLengthTool } from '@/components/tools/StringLengthTool'
const Tool = StringLengthTool
---

<Tool client:load />

対応表も添字アクセスも無い。ただの別名だ。それでもNoMatchingImportになる。コンパイラは値の流れを追っているわけではなく、タグに書かれた識別子がimport文に出てくるかどうかだけを見ている。Toolという名前をimportしていないから一致しない、それだけ。

client:onlyだけは診断が出る

同じ動的タグでも、client:onlyを付けたときはコンパイラが診断を出す。

Unable to find matching import statement for client:only component
  hint: A client:only component must match an import statement, either the
        default export or a named exported, and can't be derived from a
        variable in the frontmatter.

can't be derived from a variable in the frontmatterとはっきり書いてある。client:loadにはこの診断が無い(transformのdiagnosticsが空配列で返ってくる)。同じ制約なのに、片方は名指しで教えてくれて、もう片方はビルドの後半で汎用のエラーになる。

まとめるとこうなる。

書き方component-pathコンパイラの診断ビルド
<Foo client:load />出るなし通る
<Tag client:load />出ないなし落ちる
<map.foo client:load />出ないなし落ちる
<Tag client:only="react" />出ない出る落ちる

回避策は、島を立てるだけのフレームを1枚はさむ

client:*が静的なタグにしか付かないなら、静的なタグを書ける場所まで降りればいい。ツール1本につき、島を立てるだけの.astroを1枚置く。

---
import { StringLengthTool } from '@/components/tools/StringLengthTool'
import ToolFrame from '@/components/ToolFrame.astro'
---

<ToolFrame><StringLengthTool client:load /></ToolFrame>

これで6行。あとはページ側で、slugからフレームを引く対応表を持つ。

---
const frames = {
  'string-length': StringLengthFrame,
  'random-string': RandomStringFrame,
  // ……
} as const

const Frame = frames[tool.id as keyof typeof frames]
if (Frame === undefined) {
  throw new Error(`ツール本体のフレームが登録されていません: ${tool.id}`)
}
---

<Frame />

.astroはclient:*を取らないので、<Frame />は動的なタグのままでいい。client:loadはフレームの中に閉じ込められていて、そこでは静的なタグとして書かれている。

as keyof typeof framesのキャストがあるので、TypeScriptから見るとFrameはundefinedになり得ない。だからastro checkは通る。mdxだけ足してフレームの登録を忘れた状態を作って確かめたら、astro checkは0 errorsのまま通り、astro buildが「ツール本体のフレームが登録されていません」で落ちた。型で守れないぶんを実行時のthrowで拾う形になっている。

フレームを15枚静的importしても、クライアントには出ない

ページが15個のフレームを全部importするので、1ページに15本ぶんのJSが乗るんじゃないかと気になった。乗らない。

ビルド結果を数えると、各ツールページの<astro-island>は1個で、component-urlもそのツールのぶんだけだった。

ページastro-island読むJS
/tools/string-length1StringLengthTool…js 4.2KB
/tools/qr-code1QrCodeTool…js 28KB
/tools/metronome1MetronomeTool…js 5.6KB

ツールのJSを全部足すと127KBある。フレームは.astroでサーバー側にしか存在せず、ブラウザが読むのは<astro-island>のcomponent-urlが指す1本だけなので、静的importの本数はクライアントの荷物に効かない。

ついでに、参照の実在もビルドで落とす

content collectionのfrontmatterに関連ツール・関連記事のslugを書けるようにしてある。schemaはz.array(z.string())なので、存在しないslugを書いても型では止まらない。ここも解決時にthrowする。

const related = tool.data.related.map((slug) => {
  const found = allTools.find((item) => item.id === slug)
  if (found === undefined) throw new Error(`関連ツールが見つかりません: ${slug}(${tool.id})`)
  return found
})

存在しないslugを混ぜて試すと、astro checkは0 errorsのまま通って、ビルドが関連ツールが見つかりません: nonexistent-tool(string-length)で落ちる。

このthrowを外してfound!にするとどうなるかも試した。

[ERROR] TypeError: Cannot read properties of undefined (reading 'id')

どのツールのどのslugが悪いのかが消える。書いてあることはfoundがundefinedだという事実だけで、そこからrelatedに打ち間違いがあると気づくには、この行を読みに行くしかない。3行のthrowが買っているのはこの差だ。

ビルド後にdistのリンク切れも検査しているけど、そちらは出力されたHTMLしか見ない。フレームの登録漏れは、ツールの出ないページが正常に生成されるだけなので、リンク検査には引っかからない。参照の整合はリンク検査に任せきれない、というのがここの結論。

client:*が「テンプレートに書かれたタグ名」としか話をしていない、と分かってしまえば、変数のタグに付かないのは当たり前だった。分かるまでに時間がかかったのは、コンパイラが黙ってpropを2つ落として、ビルドの後半になってから汎用のエラーで落ちるから。client:onlyにだけ名指しの診断があるのが、かえって紛らわしい。

回避は島を立てるだけの.astroを1枚はさむだけで、静的importが15本に増えてもクライアントの荷物は増えない。そのかわり対応表と参照の整合は型で守れなくなるので、解決時のthrowにslugを入れて、ビルドを落とす側に倒してある。

← 一覧へ戻る