LDRライクなRSSリーダのIrodrをReact 19 + Vite+で書き直して、iPhone Duoにも対応した
Inoreaderのフロントエンドとして動くLDRライクなRSSリーダのIrodrを、React 19 + Vite+で書き直しました。
Irodrは2017年にLDRが終了するときに作ったもので、そこから9年ぐらい毎日使っています。
今回の書き直しでも見た目とキー操作(j/k/s/aなど)は変えていません。
中身は全部書き直していて、InoreaderとGitHub Notificationsを同じ「ソース」として扱う作りになっています。
JSのサイズはgzip後で397kBから150kBになりました。
Irodrについては次の記事で書いています。
- LDRがサービス終了のため、Inoreader/Feedlyをバックエンドに動くRSSリーダを書いている | Web Scratch
- LDRライクなRSSリーダのIrodr 1.0.0をリリースした | Web Scratch
きっかけ
きっかけは、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を入れて直した
- stickyな見出しの高さの分だけ
- 同じフィードが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を連打したときに読み込み中のフィードからさらに先へ進めるようになっています。
読み込み中のフィードは点線の枠で表示されます。
書き直した後の変更
書き直しをマージした後も、使っていて気になったところを直しています。
- feat(reader): keep read items viewable while their feed stays listed · Pull Request #128
- GitHub Notificationsは未読の通知しか返さないので、既読にしたリポジトリへ戻ると空になっていた
- Readerが既読にした記事を覚えておいて、フィードが一覧に残っている間は表示するようにした
- fix(ui): make the feed list as light as irodr 1.x · Pull Request #129
- フィード一覧の見た目を1.xと同じ軽さにした
- fix(github): mark each notification read instead of the whole repository · Pull Request #130
- GitHubの既読にする処理をリポジトリ単位の
PUTからスレッド単位のPATCH /notifications/threads/{id}に変更した - リポジトリ単位だと未読のまま残るケースがあった
- GitHubの既読にする処理をリポジトリ単位の
- Add translation mode with smart batching and link preservation · Pull Request #131
Shift+Tで記事を英語から日本語へ翻訳するモード- 見えている段落を優先して翻訳し、リンクは壊さずにラベルだけ翻訳する
- feat: あとで読む(Inoreader の Saved web pages に保存)を追加 · Pull Request #137
- feat(ui): support iPhone Duo and touch screens (sidebar drawer, tap controls for j/k/s) · Pull Request #138
「あとで読む」は、SourceインターフェースにsaveForLaterとremoveFromLaterを任意のメソッドとして足しただけで、Readerは対応しているソースに保存を頼むだけです。
そのため、GitHub Notificationsの記事をInoreaderに保存するような、ソースをまたぐ操作もUI側の分岐なしで書けました。
iPhone Duoの折りたたみ表示
iPhone DuoでもIrodrを読めるように、折りたたみ表示に対応しました。
irodrでiPhone Duoの縦の折りたたみ表示に対応した Viewport Segments APIを使ってます。 https://t.co/ZFPPSzDUXP
— azu (@azu_re) October 25, 2026
画面がどう分かれているかは、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を立ててください。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。