🤔

Astro IconでのぞいたIslands Architectureの深淵

2024/10/21に公開

はじめに

Astroには沢山の便利なインテグレーションが存在しますが、その中で比較的使われる事が多いと思われる astro-icon@astrojs/react でハマった点があったので備忘録です。

補足

この検証を行っている時点での各ライブラリのバージョンは以下になります。

  • astro: 4.16.5
  • astro-icon: 1.1.1
  • @astrojs/react: 3.6.2

astro-icon

astro-iconは、例えば新規ウィンドウで開くアイコンやアローといったページ内で使い回されるsvgの使用とコード出力を効率化してくれるインテグレーションです。
事前に /src/icons にsvgを格納し、ライブラリが用意してくれている Icon コンポーネントのname属性にファイル名を渡してあげることでsvgがレンダリングされます。
その際、 /src/icons に格納されているオリジナルのsvgが以下のようなコードになっていた場合、

triangle.svg
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
  <path fill="#333" d="M16 10 5.5 16.062V3.938L16 10Z"/>
</svg>

Icon コンポーネントは初回のレンダリング時のみ

<svg width="1em" height="1em" viewBox="0 0 20 20" data-astro-cid-j7pv25f6="" data-icon="triangle">
  <symbol id="ai:local:triangle">
    <path fill="#333" d="M16 10 5.5 16.062V3.938z"></path>
  </symbol>
  <use xlink:href="#ai:local:triangle"></use>
</svg>

このように元のコードを symbol タグでラップした物+表示する用の use タグで出力し、2回目以降の呼び出しでは

<svg width="1em" height="1em" viewBox="0 0 20 20" data-astro-cid-j7pv25f6="" data-icon="triangle">
  <use xlink:href="#ai:local:triangle"></use>
</svg>

1回目の呼び出し時に生成した symbol タグを参照する use タグのみ出力してくれるといった具合です。

@astrojs/react

AstroはIslands Architectureを採用しており、ページ内で使用するコンポーネントをReact、Vue、Svelteといった他のUIフレームワークで書くことが可能です。
Astroは基本的にビルド時にすべてのHTMLをレンダリングし、クライアントサイドでの複雑なインタラクションやデータフェッチ等を行う必要があるコンポーネントのみアイランドとしてハイドレーションを行うことで高いパフォーマンスを実現します。
@astrojs/react は名前の通りプロジェクト内でReactコンポーネントを採用する際に必要な公式インテグレーションです。

何が起こったのか

astro-icon で表示していた何個かのsvgのうち、一つがページ内ですべて表示されなくなりました。
調べてみるとsvg タグとその中の use タグは出力されているのですが、初回呼び出し時に出力されるはずの symbol タグがページのどこにも出力されていませんでした。参照すべき symbol が存在しないので表示されないのは当然ですね。

astro-iconIcon コンポーネントのコード見てみると、

https://github.com/natemoo-re/astro-icon/blob/333f614d5d6e5f11c47a7b011e244074d9813b0c/packages/core/components/Icon.astro#L27-L37

Icon コンポーネントが呼び出された際、呼び出したsvgのファイル名をキャッシュに格納する事を試み、その結果次第で includeSymbol というフラグ変数をコントロールし、true(初回のレンダリング)であれば symbol タグも出力する、という比較的シンプルな構造になっていたため尚更不思議でした。

原因は何だったのか

端的に言うと以下の2点が発生条件でした。

  • Reactコンポーネントにslotを使用し astro-iconIcon コンポーネントを渡す
  • Reactコンポーネント内で、渡された Icon コンポーネントを最終的にレンダリングしていなかった

Astroでは、Reactで言うchildrenのようにスロットという方法でコンポーネントに子要素を渡すことができます。

例えば

SampleComponent.astro
<div class="sampleComponent">
  <slot name="icon"/>
  <slot name="text"/>
</div>

上記のようなコンポーネントを以下のように呼び出すことで

index.astro
<SampleComponent>
  <Icon name="triangle" slot="icon"/>
  <p slot="text">テキスト</p>
</SampleComponent>

最終的にこのようにレンダリングされます。

index.html
<div class="sampleComponent">
  <svg width="1em" height="1em" viewBox="0 0 20 20" slot="icon" data-astro-cid-j7pv25f6 data-icon="triangle">
    <use xlink:href="#ai:local:triangle"></use>
  </svg>
  <p data-astro-cid-j7pv25f6>テキスト</p>
</div>

Reactコンポーネントにも同様に、slotでコンポーネントを渡すことができます。

ReactComponent.tsx
export function ReactComponent({ icon }: { icon?: React.ReactNode }) {
  return <div className="reactComponent">{!!icon && <>{icon}</>}</div>;
}
index.astro
<ReactComponent>
  <Icon name="triangle" slot="icon"/>
</ReactComponent>

このReactコンポーネントが何らかの条件でsvgの表示/非表示をコントロールしていて、 Icon コンポーネントを受け取っているにも関わらずレンダリングしなかった場合、 symbol が出力されなくなりました。

極端に分かりやすく簡略化すると以下のようなケースです。

ReactComponent.tsx
export function ReactComponent({ icon }: { icon?: React.ReactNode }) {
  const isShowIcon = false;
  return (
    <div className="reactComponent">{!!icon && isShowIcon && <>{icon}</>}</div>
  );
}

考察・検証

astro-icon は、該当セクションで説明した通り「nameで指定した各svgファイルの、それぞれ初回呼び出し時に symbol タグを出力する」動作になっており、つまり 「Reactコンポーネント内では、最終的にレンダリングされなかっただけでコンポーネントの実体化は行われ、そこで初回呼び出し時の処理(=symbolタグの出力)が完了した事になった」 という状況に見えます。

関連ドキュメントを確認してみます。

https://docs.astro.build/ja/guides/framework-components/#フレームワークコンポーネントに子要素を渡す

childrenにslot名で個別のコンポーネントを渡す事自体は想定している挙動ですが、

https://docs.astro.build/ja/guides/integrations-guide/react/#子要素のパース

@astrojs/reactの"子要素のパース"セクションに

AstroコンポーネントからReactコンポーネントへ渡される子要素は、Reactノードではなくプレーンな文字列としてパースされます。

とあり、基本的にコンポーネントを渡す事はできるがその時点でHTML文字列化されるようです。astro.configで experimentalReactChildren フラグを使うとvnodeとして伝わるとのことですが、 experimentalReactChildren: true にしても結果は変わりませんでした。

experimentalReactChildren フラグで内部的にどう処理が変わるのかは気になる所ですが、なんとなく 「Reactコンポーネントに渡された時点で Icon コンポーネントが評価されプレーン文字列になってしまう→この時点で Icon コンポーネントの内部的には初回レンダリングと判断され、 symbolタグが出力された文字列を返す→Reactコンポーネント側でそれをレンダリングしなかった」 ことで起きた事であると理解できました。

これらの情報を踏まえて確認・検証をした項目が以下になります。

Reactコンポーネントではなく通常のAstroコンポーネントで同じ事をした場合は?

発生しませんでした。
基本的にAstroコンポーネントであれば、そのコンポーネント内でどんな条件分岐があろうともslotで渡されたコンポーネントがビルド時にレンダリングされるかどうかはAstroフレームワーク側で明確になる=最終的に不必要である事が推察できる場合、渡されたコンポーネントは実体化されない?

Reactコンポーネントを書く位置によって結果が変わるか?

Reactコンポーネントを置く位置より前に、slot等を介さず普通に Icon コンポーネントを使用すればそこで symbol タグが出力されると思ったのですが、そうはなりませんでした。Import順も同様に、影響なし。
ページ内で出現する順序に関係なく、アイランドコンポーネント部分は優先的に処理されるのかもしれません。

index.astro
<Layout>

  <Icon name="triangle"/><!-- 先にこちらでsymbolが出力されそうだが、 -->

  <ReactComponent>
    <!-- 後に書いたこちらにsymbolが出力される -->
    <Icon name="triangle" slot="icon"/>
  </ReactComponent>
</Layout>

まとめ

原因が分かったため何とか対応できたのですが、結局 ページのどの位置に書かれていようが、Reactコンポーネントにslotで Icon コンポーネントが渡された場所で symbol タグが出力されてしまう(ため、 Icon コンポーネントをレンダリングしなければページ全体でそのsvgは表示されない) 事は回避できておらず、どういったロジックでそうなってしまうかは明確になってないのでモヤりが残る形になってしまいました。
また、ReactコンポーネントではなくVue,Svelteの場合同様の現象が起きるのか、といった検証はできていません。

AstroとIslands Architectureによる複数フレームワークの混在を初めて知った時、 結局最後はhtmlとjsになる と理屈では分かっていてもそれがどう実現されているのか不思議に思ったまま雰囲気でここまで来てしまっているので、折を見てしっかり理解してみようと思います。

Astroを採用している方は基本的に事前レンダリングで済むサイト構成を取っていると思うのでReactコンポーネントを積極的に使う方はそこまで多くないかもしれませんが、同じ問題に直面した方が居ましたら参考にしていただければ幸いです。

参考

https://zenn.dev/morinokami/articles/islands-architecture-with-astro

Discussion