Server Component に Context がないので、表示漏れの防止をビルドの手前に出した
ブログにアフィリエイトリンクを入れるにあたって、「広告リンクを貼ったのに PR 表記を出し忘れる」という事故だけは実装で塞ぎたかった。表記漏れは景品表示法の問題で、レビューの見落としで済む類のものではない。React Server Components では Context が使えないので、当初考えた実行時ガードは成立しなかった。
付け忘れると、記事だけが違法になる
2023年10月から、広告であることを一般消費者が判別できない表示は景品表示法の不当表示にあたる。アフィリエイトリンクには「PR」等の表示が要る。責任を負うのは掲載側ではなく事業者だ。
実装はこうなった。広告リンクは AffiliateLink というコンポーネントで貼り、これ自体が「PR」ラベルを隣に描く。加えて、記事メタに sponsored を立てると記事冒頭にも告知が出る。途中から読み始めた読者にも、記事の先頭から読んだ読者にも伝わるようにするためだ。
export type Article = {
slug: string;
// ...
/** 本文に AffiliateLink を含む記事は true。記事冒頭の PR 表記を出す。 */
sponsored?: boolean;
Body: () => ReactNode;
};危ないのは、この2つが独立していることだった。 AffiliateLink を貼っても sponsored を立て忘れれば、冒頭の告知は出ない。ページは正常に表示され、リンクも動く。壊れて見えないので、テストでもリンク検査でも見つからない。
Server Component に Context はない
最初に考えたのは、記事ページが「この記事は sponsored か」を Context で配り、 AffiliateLink 側が false なら例外を投げる形だった。記事は全部 generateStaticParams で静的生成されるので、例外はそのままビルドを落とす。
// やりたかったこと(動かない)
const SponsoredContext = createContext(false);
export function AffiliateLink({ href, children }: Props) {
if (!useContext(SponsoredContext)) {
throw new Error("sponsored: true のない記事に広告リンクがあります");
}
// ...
}これは動かない。記事本文は Server Component で、Server Component は Context を持てないからだ。ビルドすると、この文言で止まる。
You're importing a module that depends on `createContext` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
言われてみれば当然で、Context はレンダリング中に親から子へ値を渡す仕組みであり、サーバー上で一度実行されて HTML と RSC ペイロードになるコンポーネントには、その「レンダリングの木」が実行時に残らない。制約を親から子へ伝播させる手段が、そもそもない。
"use client" を付ける案を捨てた
エラーメッセージは "use client" を付けろと言っている。実際それで Context は使えるようになる。捨てた。
記事本文は静的なテキストと図で、状態もイベントハンドラも持たない。それをクライアントコンポーネントにすると、17本の記事本文がまるごと JavaScript バンドルに乗る。読者に配る JS を増やす対価が「書き手の付け忘れを防ぐ」ことだとしたら、割に合っていない。
制約を守らせたいのは書き手であって、読者ではない。読者側のコストで書き手側の規律を買おうとしていた点が、この案の間違いだった。
強制の置き場所を、実行時からビルドの手前へ
代わりに、next build の手前にソースを走査するスクリプトを置いた。記事は1ファイル1記事なので、同じファイルの中に <AffiliateLink と sponsored: true が揃っているかを見れば足りる。
const sources = readdirSync(articlesDir)
.filter((name) => name.endsWith(".tsx"))
.map((name) => ({ name, source: readFileSync(join(articlesDir, name), "utf8") }));
const missingNotice = sources
.filter(({ source }) => source.includes("<AffiliateLink") && !source.includes("sponsored: true"))
.map(({ name }) => name);
if (missingNotice.length > 0) {
console.error(`アフィリエイトリンクを含む記事に sponsored: true がありません: ${missingNotice.join(", ")}`);
process.exit(1);
}これを package.json の build の先頭に差し込む。ローカルでも Vercel でも同じコマンドが走るので、検査を通らないものはデプロイされない。
型で表現できるならそのほうがよかったが、「JSX にこの要素が現れたら、同じオブジェクトのこのフィールドが true であること」は型では書けない。書けないものを型で書こうとして "use client" まで払うより、文字列一致で十分だと判断した。
落ちるはずのものが落ちなかった
ここで一度すり抜けた。Server Component で Context が使えないことを確かめるため、検証用のページを src/app/__ctxtest/page.tsx に置いてビルドしたところ、何のエラーも出ずに成功した。
Context が使えたわけではない。Next.js は _ で始まるディレクトリを private folder として扱い、ルートを生成しない。ページが1つも作られていないので、コンパイルされる対象ですらなかった。ディレクトリ名を ctxtest に変えたら、期待どおり落ちた。
ガードを入れたときも同じことをやった。わざと AffiliateLink だけを既存記事に差し込んで、検査が exit 1 で止まることを見てから戻した。
$ node scripts/check-sponsored-articles.mjs アフィリエイトリンクを含む記事に sponsored: true がありません: text-wrap-pretty-japanese-safari.tsx 記事冒頭の PR 表記が出ないため、ステマ規制(景品表示法)に抵触します。 $ echo $? 1
まとめ
React Server Components は、コンポーネント間の制約を実行時に伝播させる手段を持たない。Context を前提にした「使い方の強制」は、クライアントコンポーネントへの格上げという対価とセットになる。その対価が読者に転嫁されるなら、強制はビルドの外側に出したほうがいい。
そして、落ちるはずのものが落ちなかったときに最初に疑うのは仮説ではなく、検査が実際に走っているかどうかだ。ガードを書いたら、ガードが落ちるところまで見る。