Inoreaderのフロントエンドとして動くLDRライクなRSSリーダのIrodrを、React 19 + Vite+で書き直しました。

Irodrは2017年にLDRが終了するときに作ったもので、そこから9年ぐらい毎日使っています。 今回の書き直しでも見た目とキー操作(j/k/s/aなど)は変えていません。 中身は全部書き直していて、InoreaderとGitHub Notificationsを同じ「ソース」として扱う作りになっています。 JSのサイズはgzip後で397kBから150kBになりました。

Irodrについては次の記事で書いています。

きっかけ

きっかけは、InoreaderのProプランのフィード数の上限です。 ProプランはRSSフィードの購読数が2500までに制限されていて、自分の購読数がこれを超えそうになっていました。

GitHubでWatchしているリポジトリのリリースは、watch-rssを使ってRSSフィードとしてInoreaderに購読しています。 (私のJavaScriptの情報収集法 2024年版でも書いています) この仕組みで購読しているGitHubリポジトリのフィードが2000以上あって、購読数の大半はこれでした。

GitHubリポジトリのリリースは、GitHubのWatch(Releasesのみ)でも受け取れます。 これをGitHub Notificationsに逃がせば、Inoreaderのフィード数を2000ほど減らせます。 ただ、GitHub Notificationsの画面はRSSリーダのようには読めないので、IrodrでGitHub Notificationsをリポジトリ単位のフィードとして読めるようにしました。

これで、InoreaderのフィードとGitHub Notificationsを同じ画面、同じキー操作で読めるようになりました。 そのため、GitHubのフィードはInoreaderから消せました。

ただ、このGitHub Notifications対応は、Inoreader向けだったIrodrに後から足したものだったので、結構無理やりな作りになっていました。 Irodr 1.xは、状態管理にAlmin、UIにOffice UI Fabricを使っています(1.0.0の記事参照)。 ビルドは、1.0の頃はcreate-react-app-typescriptで、その後react-scripts 5に移行していました。 Create React Appはすでに非推奨になっています。 Office UI Fabric(今のFluent UI)も、使っていた7系は古いままでした。

そのため、InoreaderとGitHubを同じ扱いにするのも兼ねて、全部書き直すことにしました。

書き直しの方針

書き直しはClaude Codeでやりました。 最初に、音声入力で次のような方針を伝えています。

  • 技術スタックを最新にして、依存はできるだけ減らす
  • React Hooksに移行して、クライアントサイドのSPAのままにする
  • LDRのように、キーを押したらすぐ反応する作りにする
  • GitHub NotificationsもInoreaderと同じ扱いにして、ソースを増やしてもUI側は何もしなくていい作りにする
  • Vite+を使って、LintやフォーマッターはできるだけStrictにして、AIが書けるようにする
  • 外部サービスのAPIを再現したFake Serverを用意して、それに対してインテグレーションテストを書く
  • UIフレームワークは使わず、StyleXでスタイルを書く

インテグレーションテストはPlaywrightかVitestのBrowser Modeか迷っていましたが、OAuthのリダイレクトでページ遷移が必要だったのでPlaywrightになりました。

最初の実装は1時間ぐらいで出てきて、そこから半日ぐらいレビューと修正を繰り返して、PRをマージしています。 この辺の試行錯誤は後述します。

技術スタック

1.xと2.0の主な違いは次のとおりです。

1.x 2.0
ビルド・開発 Create React App (react-scripts 5) Vite+ (vp dev / vp build)
UI React 18 + Almin + Office UI Fabric 7 React 19(関数コンポーネントとhooks)+ React Compiler
スタイル CSS + Office UI Fabric StyleX。UIフレームワークなし
Lint・整形 整形のみ(Prettier + lint-staged) Oxlint + Oxfmt + 型チェック(vp check)
テスト Jest Vitest + Playwright
ブラウザで動く依存の数 48 4 (react, react-dom, @stylexjs/stylex, markdown-it)

Vite+

Vite+は、Vite、Rolldown、Vitest、tsdown、Oxlint、Oxfmtなどをvpという1つのCLIにまとめたツールチェーンです。 Irodrでは開発サーバー、ビルド、テスト、Lint、フォーマット、pre-commitフックまでをvpだけでやっています。

  • vp check: 整形・Lint・型チェック
  • vp test: 単体テスト(Vitest)
  • vp build: 本番ビルド

設定ファイルをツールごとに持たなくていいのと、Lintとフォーマットが速いので、ローカルでもCIでも待ち時間がほとんどありません。 本番ビルドの時間は1.xの27秒から0.5秒ぐらいになりました。

パッケージのバージョンは、pnpm workspaceのcatalog:に全部固定で書いています。 catalogMode: strictにしてるので、catalogにないバージョンは入りません。

React 19 + React Compiler(Oxc版)

UIはReact 19の関数コンポーネントとhooksだけで書いて、メモ化はReact Compilerに任せています。

最初はBabel版のReact Compilerを使っていました。 ただ、OxlintのルールにReact Compiler向けのものが入っていたので、OxcがReact Compilerを再実装しているのではないかと思って聞いてみました。 すると、OxcによるRust移植版(oxc-transform-react)を教えてもらいました。 @vitejs/plugin-reactのreact({ compiler: true })で有効にできます。 Babel版からOxc版に切り替えたら、ビルドの出力はモジュールの順番以外は同じで、ビルド時間は1.7秒から0.7秒になりました。 Babel関係の依存も消せています。 Oxcのドキュメントではまだexperimentalという扱いです。

React Compilerで気をつけないといけないのは、コンパイラが扱えないコンポーネントは黙ってスキップされることです。 レンダリング中の変更やrefの読み取りなどがあると、そのコンポーネントだけメモ化されません。 Irodrでは、Oxlintに入っているReact Compilerのルール(react/purity、react/refsなど)を有効にしています。 コンパイラの扱えないコードがあるとvp checkは失敗します。

StyleX

スタイルはStyleXで書いています。 stylex.createでスタイルを定義してコンポーネントに渡すだけの使い方で、UIフレームワークは使っていません。 1.xではOffice UI FabricのListコンポーネントなどを使っていましたが、2.0ではリストやフォーカス管理は自前です。 LDR風のUIはそもそも装飾がほとんどないので、UIフレームワークがなくても特に困りませんでした。

ユーザースクリプトから参照される.SubscriptionContentsContainer-*のようなクラス名は、StyleXが生成するクラス名とは別に残しています。

Sourceインターフェース

2.0で一番大きな変更は、InoreaderとGitHub Notificationsをどちらも同じSourceインターフェースを実装する「ソース」にしたことです。

src/
  sources/   ソース。source.ts が共通のインターフェース (Source, Feed, Item, capabilities, settings)
    inoreader/  OAuth 2.0、購読一覧、記事の読み込み、mark-all-as-read
    github/     Notifications API、既読にする処理、IndexedDB キャッシュ
  app/       Reader(React に依存しない)
    reader-model.ts  immutable な状態と、その遷移の関数
    reader-view.ts   画面に渡す形への変換
    reader.ts        createReader: 通信・先読み・既読・自動更新などの副作用
  ui/        React コンポーネント、キーボードショートカット、User Script API
  lib/       小さな共通処理(store、IndexedDB、サニタイザー、キーバインド)
  main.tsx   ソース・Reader・UI をつなぐ唯一の場所

依存の向きはui → app → sources → libの一方向だけです。 UIとReader(状態管理の中心)は、どのソースのフィードかを知らずに動きます。 src/main.tsxだけがどのソースがあるかを知っていて、新しいソースを足すときもsrc/appとsrc/uiを変える必要はありません。

ソースごとの違いは、各ソースの中に閉じています。 たとえば、Inoreaderはフィードごとに記事を取得しますが、GitHubは未読の通知を全部IndexedDBへキャッシュします。 既読にするAPIもソースごとに違います。 画面側には、capabilities(loadMore / unreadFilter / liveItems)と、データで書いた設定フォーム(SourceSettings)だけを見せています。

この辺の詳細はdocs/architecture.mdに書いています。

immutableな書き方をLintで強制する

最初に出てきた実装は、Readerなどがクラスで書かれていました。 自分はimmutableな書き方の方が好みなので、まずlet、引数への再代入、Object.assignを禁止するLintを入れてもらいました。 Oxlintにはno-restricted-syntaxがないので、letを禁止するルールはOxlintのJavaScriptプラグインとして書いています。 このプラグインはlib/oxlint-plugin-immutableというpnpm workspaceのパッケージにしています。 immutable/no-letに加えて、Errorを継承する場合以外のクラスを禁止するimmutable/no-classも入っています。

その後、Reader自体もクラスではなくfunctionalにして、他のコードも同じにしてもらいました。 今のルールは次のような感じです。

  • クラスは使わない。extends Errorだけはinstanceofのために残す
  • 何か処理を持つモジュールはcreateX(options)という関数で作る。返すのはアロー関数を並べたオブジェクトなのでthisはない
  • 状態はstoreに置いて、変えるときは新しい値を作って丸ごと差し替える
  • 状態の遷移は(state, input) => stateの形の関数にする。ソースやタイマーなしで単体テストできる
  • 通信やタイマーなどの副作用は、createXのクロージャの中だけで扱う

クラスからfunctionalへの書き換えでは、古いReaderと新しいReaderに同じ操作を流して結果を比べる形でチェックしています。 書き換えのときは、記録した30個のシナリオで差分が0であることを確認しました。 マージ前の最終レビューでは、さらにランダムに生成した8800回ぐらいの操作で比べて、差分が0であることを確認しています。

1.xではDDD的なレイヤードアーキテクチャで、ドメインモデルをクラスで書いていました。 2.0では状態の遷移がただの関数になっているので、Readerの処理(フィード移動、先読み、既読にする処理)のテストは、ソースやタイマーなしで書けます。 書き方のルールがLintでチェックされるので、AIに書かせたコードでも、レビューで書き方の指摘をする必要がなくなります。

対応ブラウザはBaseline Widely available

対応ブラウザについては、ちょっと行ったり来たりしました。 レビューでMap.groupByやtoSortedなどES2024の機能が古いSafariで動かないと指摘して、一度ES2022で書き直してもらいました。 ただ、考え直すとモダンブラウザだけで良かったので、最終的にはBaseline Widely available(主要ブラウザ全部で2.5年以上使える機能)を基準にしました。 Map.groupByやtoSortedはWidely availableなので、そのまま使っています。

ビルドはViteのデフォルトのbaseline-widely-availableを対象にしています。 ただし、足りない組み込み関数を補うpolyfillは入れていないので、eslint-plugin-baseline-jsで組み込み関数とWeb APIをチェックしています。 例外は後述するTranslator APIだけで、あるときだけ使う機能なので許可しています。

Fake Serverに対するテスト

テストは、Inoreader / GitHub APIのFake Server(e2e/fake-api/)に対して動かしています。 単体テスト(Vitest)とインテグレーションテスト(Playwright)のどちらも同じFake Serverを使います。 Fake Serverは実際のAPIの動作に合わせて作っていて、リクエストを記録して検証もできます。 OAuth、購読一覧、続きの読み込み、mark-all-as-read、GitHubのLinkヘッダーでのページ送りや既読にしたときのレスポンスなどを実装しています。

インテグレーションテストは本番ビルド(vp build --mode e2e)をvp previewで配信して、Playwrightでキー操作をして確認しています。 フィード移動、既読にする処理、先読み、エラー時の動作、ユーザースクリプトAPIなどが対象です。 本物のAPIに依存しないので、CIで安定して動きます。

CIのインテグレーションテストは、PlaywrightのDockerイメージを使う形も試しました。 Irodrぐらいの規模だとイメージの取得の方がplaywright installより遅くて、ジョブは62秒から85秒ぐらいになりました。 それでも、ブラウザとシステムの依存がイメージに入っていて、毎回playwright install --with-depsしなくて済むので、今もDockerイメージを使っています。

1.xから引き継いだもの

書き直しではありますが、次のものは1.xと互換性があります。

  • Inoreaderのログイン状態と、GitHubのトークンとキャッシュ(IndexedDBの形式は1.xと同じ)
  • User Script API(window.userScript、userscript-initイベント、SubscriptionContent::*イベント)
  • ユーザースクリプトが使うクラス名(.SubscriptionContentsContainer-*、.SubscriptionListContainer-item、.ng-content)
  • 1.xで保存した設定(初回起動時に引き継ぐ)
  • Netlify Edge FunctionのCORSプロキシ

そのため、既存のユーザースクリプトはそのまま動くはずです。

使ってみて見つけた問題

実装が出てきたあとは、実際に使ってみて気になったところをスクショや画面収録と一緒に伝えて直してもらう、というのを繰り返しました。

  • sで移動したときに、今いるフィードがカテゴリの見出しの下に隠れる
    • stickyな見出しの高さの分だけscroll-padding-topを入れて直した
  • 同じフィードが2つ表示されて、sが効かなくなる
    • Inoreaderが同じ購読を2回返すことがあって、フィードIDが重複していた。1.xはMapで持っていたので気づかなかった
  • blockquoteの見た目が1.xと違う
    • 新しいCSSに独自のスタイルが入っていた。1.xと計算後のスタイルを比べて、ブラウザのデフォルトに戻した
  • InoreaderのClient IDとSecretの欄が、何も入れてないと未入力に見える
    • 「Use your own Inoreader app」として折りたたんで、値があるときだけ開くようにした

この辺は、自動テストでは見つけにくいので、実際に使ってみるのが一番早かったです。

パフォーマンス

1.xと2.0のビルドを、Fake ServerのInoreader APIに40フィード × 20記事を入れた同じ条件で、Playwright(Chromium)から比べました。 CIのクラウド環境での相対的な比較なので、手元のマシンの数値ではありません。 詳しい条件はPR #127に書いています。

項目 1.x → 2.0(CPU等速) 1.x → 2.0(CPU 4倍遅い)
フィード一覧が出るまで 376 → 228 ms 1,188 → 570 ms
FCP(最初の描画) 268 → 116 ms 1,016 → 384 ms
sで次のフィードの記事が出るまで(先読み済み) 66 → 23 ms 215 → 111 ms
30フィード読んだあとのJSメモリ 9.7 → 5.3 MB 9.8 → 5.2 MB
1.x 2.0
JS(gzip後) 397 kB 150 kB
CSS(gzip後) 30.9 kB 4.2 kB
本番ビルドの時間 27秒 0.5秒

この表のサイズとビルド時間は、2026年11月7日時点のmasterで測り直した数値です。 書き直した直後はJSが136kBでしたが、後述する「あとで読む」やタッチ画面への対応を足したので少し増えています。

jで次の記事へスクロールする時間は元々10msぐらいで、どちらも同じぐらいです。 LDRライクなリーダとして一番大事なのは、sを押したら待たずに次のフィードへ切り替わることです。 これは先読みの仕組みで1.xの頃からやっていました。 2.0では、sを連打したときに読み込み中のフィードからさらに先へ進めるようになっています。 読み込み中のフィードは点線の枠で表示されます。

書き直した後の変更

書き直しをマージした後も、使っていて気になったところを直しています。

「あとで読む」は、SourceインターフェースにsaveForLaterとremoveFromLaterを任意のメソッドとして足しただけで、Readerは対応しているソースに保存を頼むだけです。 そのため、GitHub Notificationsの記事をInoreaderに保存するような、ソースをまたぐ操作もUI側の分岐なしで書けました。

iPhone Duoの折りたたみ表示

iPhone DuoでもIrodrを読めるように、折りたたみ表示に対応しました。

画面がどう分かれているかは、Viewport Segments APIで取得しています。 Safari 27.1では、このAPIはiPhone Duo向けの開発者プレビューという扱いです。 Safariの設定にある機能フラグで「Device Posture API」と「Viewport Segments API」を有効にすると使えます。

APIがないブラウザでは、今までどおりのレイアウトになります。

ヒンジの角度を取るAPIはありません。 ただ、折りたたみの状態かどうかや、画面が左右と上下のどちらに分かれているかは判定できます。 Irodrでは、この「どちらに分かれているか」を見てレイアウトを変えています。

実装では、JavaScriptのwindow.viewport.segmentsは使っていません。 CSSのメディア特性であるhorizontal-viewport-segmentsとvertical-viewport-segmentsだけを使っています。 StyleXのdefineConstsでメディアクエリに名前を付けて、各コンポーネントのスタイルから参照しています。

export const media = stylex.defineConsts({
    // 本のように左右に開いたとき: フィード一覧を細くする
    sideBySide: "@media (horizontal-viewport-segments: 2) and (min-width: 600px)",
    // ノートPCのように上下に開いたとき: フィード一覧はアイコンだけ、下の画面にボタン
    stacked: "@media (vertical-viewport-segments: 2) and (pointer: coarse) and (min-width: 600px)"
});

min-width: 600pxを付けているのは、600pxより狭いときはフィード一覧を引き出す形で表示していて、そちらを優先するためです。 ボタンの並べ方を変えるところだけは、同じメディアクエリをmatchMediaで見ています。

最初は、本のように左右に開いたら、左の画面をフィード一覧、右の画面を記事にしていました。 ただ、半分に分けると記事が狭すぎたので、次のような形に変えています。

  • 左右に開いたとき(本のような形): フィード一覧を160pxの細い表示にする
  • 上下に開いたとき(ノートPCのように置いた形): フィード一覧はアイコンだけにして、下の画面の下半分にk/j/sなどのボタンを置く

上下に開いたときのボタンは記事のスクロール領域の中に置いているので、ボタンの上をスワイプしても記事をスクロールできます。

Safari 27.1では、env(viewport-segment-height 0 1)が実際の画面よりかなり大きな値を返して、ボタンが画面を覆ってしまいました。 iPhone Duoは真ん中で折れるので、ボタンの高さはビューポートの高さの4分の1で決めています。

翻訳の試行錯誤

Shift+Tの翻訳はブラウザのTranslator APIを使っていますが、これはChromeとEdgeにしかありません。 FirefoxでもIrodrを使いたいので、どのブラウザでも翻訳できるようにする方法を色々試しました。

最初に試したのは、ローカルサーバーとして動くシングルバイナリ版のIrodrです。 MacならApple Translation frameworkで翻訳ができます。 hotchpotch/trnのようにSwiftで小さな翻訳コマンドを書いて、ビルド済みのIrodrと一緒にvp packで1ファイルにするという作りです。 vp packはtsdownのexeオプションを使っていて、中身はNodeのSingle Executable Applicationsです。 ローカルサーバーがあればShift+Tはそれを使い、なければ今までどおりTranslator APIを使います。

これは実際に動きました。 また、Appleの翻訳はAttributedStringをそのまま翻訳して、リンクや強調の位置も翻訳後の文に合わせてくれます。 そのため、リンクを含む段落もそのまま翻訳できました。 ただ、実行ファイルがNode本体を含むので150MBぐらいになります。 色々試した結果、ローカルサーバーを立ち上げてから使うという形は、ウェブアプリとして使っているIrodrとはちょっと違う感じがして、一旦やめました。 コードは残してあるので、ローカルのLLMと連携するようなことをやるときには、この仕組みを使う可能性があります。

この試行錯誤から本体に取り込んだのは、見えている範囲の段落を優先して翻訳する仕組みです(PR #131)。

AIで書き直してみて

1万4千行ぐらいの追加になる書き直しを自分で一から書くのは大変なので、今回はClaude Codeに書いてもらいました。 既存の動作(キー操作、既読にするタイミング、User Script API)が仕様で、Fake Serverに対するテストで確認しながら進める形です。

やってみて良かったのは、コードの書き方(functional、immutable)をLintで強制したことと、AGENTS.mdにコマンドとルールをまとめたことです。 人とAIが同じ手順(vp checkとvp test)で確認するので、レビューは動作の話に集中できます。

一方で、実際に使ってみないとわからない問題(見出しに隠れる、スタイルが違う)は結構ありました。

おわりに

Inoreaderのフィード数制限をきっかけに、Irodrを作ってから9年経ってようやくコードベースを書き直しました。 使い方は変わっていないので、これまで使っていた人はそのままhttps://irodr.netlify.app/を開けば使えます。

何かおかしなところがあれば、Issueを立ててください。