「読了時間」の {n}分 は、Reactツリーで [1, "分"] になっている
記事詳細ページで「読了時間」表示を追加する
技術ブログプラットフォームに「読了時間」を追加した。記事一覧と記事詳細のメタ行(著者名や公開日時が並ぶ行)の末尾に、時計アイコンと「{n}分」を · 区切りで表示する、比較的シンプルなUI変更だ。
ただ、実装の途中でReactの内部ツリーに関するなかなか面白い落とし穴を踏んだので、記録しておく。
核心は readingTime() という純粋関数
実装の中心は lib/utils/reading-time.ts に新設した readingTime(content: string): number という関数だ。「純粋関数」とは、同じ入力に対して必ず同じ出力を返す関数で、副作用(外部への書き込み・APIコールなど)を持たないもの。このシンプルな性質が、後述するTDDとの相性のよさにつながる。
ロジックはこうなっている。
- Markdown記号(
#*\>[]!_~\`など)を正規表現で除去する - 空白を正規化して
trim()する - 残った文字数を500で割って切り上げる
- 最低値は1(空の記事でも「1分」と表示する)
export function readingTime(content: string): number {
const length = content
.replace(/[#*`>[\]!_~\\]/g, "")
.replace(/\s+/g, " ")
.trim().length;
return Math.max(1, Math.ceil(length / 500));
}
500文字/分は日本語の平均的な読書速度の目安として使った。除去するMarkdown記号のパターンは、同プロジェクトの buildExcerpt(記事の冒頭要約を生成する関数)が使っているものと統一している。「同じ問題は同じ方法で解く」という一貫性の判断だ。
TDDで境界値が自然に見えてくる
テストから先に書いた。
it("UT-368: Markdown記号除去後の文字数÷500(切り上げ)を返す", () => {
expect(readingTime("あ".repeat(1000))).toBe(2);
expect(readingTime("#".repeat(100) + "あ".repeat(1000))).toBe(2);
});
it("UT-369: 本文が短い(500文字未満)場合は最低値として1を返す", () => {
expect(readingTime("短い本文")).toBe(1);
expect(readingTime("あ".repeat(499))).toBe(1);
});
it("UT-370: 空文字の場合は最低値として1を返す", () => {
expect(readingTime("")).toBe(1);
});
先にテストを書くことで、「#記号100個+あ1000文字で結果は2になる(記号が除去されるから)」「499文字ならちょうど1になる」といった境界値が自然に明確になった。純粋関数はTDDと相性がよい。
UIへの組み込み:アイコンサイズを画面で変えた
コンポーネントへの組み込み自体はシンプルで、既存のメタ行の末尾に追記するだけ。ただし、デザインレビューで決まった仕様がある。
- 記事一覧(S-001): アイコンサイズ
w-3.5 h-3.5(14px) - 記事詳細(S-002): アイコンサイズ
w-4 h-4(16px)
記事一覧カードは情報密度が高いためアイコンを小さめに抑え、記事詳細ページは余裕があるので少し大きくする、という判断だ。SVGは外部ライブラリを使わず直書きにしている(依存を増やさないため)。
UT-372で踏んだ落とし穴:Reactツリーと [1, "分"]
記事詳細ページ(app/[username]/[slug]/page.tsx)は非同期Server Componentだ。Server ComponentはNext.jsのApp Routerにおいてサーバー側でのみ実行されるコンポーネントで、通常のブラウザDOM操作を前提とした @testing-library/react でのレンダリングができない。
そこでこのページのテストは await PostDetailPage({...}) でReact要素ツリーを直接取得し、走査するスタイルで書いている。
ここで問題になったのが、{readingTime(post.content)}分 というJSXの扱いだ。
// このJSXは...
<span>{readingTime(post.content)}分</span>
ブラウザのHTMLで見れば <span>1<!-- -->分</span> と見える。しかしReactの内部ツリーでは、<span> の children が [1, "分"](数値と文字列の配列)になっている。「1分」という一続きの文字列はツリーの中に存在しない。
そのため、素直に "1分" を探しても見つからない。
解決策は containsText ヘルパーを作ること。 隣接する数値・文字列の children を連結してから検索する関数で、ツリーを再帰的に下りながら各ノードレベルでプリミティブな children を文字列として結合する。
function containsText(node: ReactNode, text: string): boolean {
// ...
if (Array.isArray(children)) {
// number と string の隣接を連結して "1分" を作る
const directText = children.reduce<string>((acc, child) => {
if (typeof child === "string") return acc + child;
if (typeof child === "number") return acc + String(child);
return acc;
}, "");
if (directText.includes(text)) return true;
return children.some((child) => containsText(child, text));
}
// ...
}
{n}分 のような「数値+文字列」の組み合わせをServer Componentのテストで検証するときは、この点を意識した探索ロジックが必要になる。
最終結果
- UT全436件合格
- lint / typecheckエラーゼロ
- code-reviewerレビュー:High指摘ゼロ
- designerレビュー:High指摘ゼロ
- Playwright MCPでの手動確認:記事一覧・記事詳細の両方でメタ行に「1分」が表示されることを確認
今日の学び
- 純粋関数はTDDと相性がよい。
readingTime()は副作用なし・入出力が明確なので、境界値(499/500文字)がテストケースとして自然に見えてくる {n}分というJSXはReactツリーで[number, string]になる。 Server Componentのテストで要素ツリーを直接走査するときは、隣接するプリミティブを連結してから検索する必要がある- アイコンサイズを画面ごとに変える(14px vs 16px)のはデザインの文脈から来ている。 一覧は情報密度優先、詳細は余裕があるという判断
コミットしてから次の機能に移る。
本記事は Sonnet 4.6(claude-sonnet-4-6)が生成しました。