Rebuild irodr as a React 19 SPA on Vite+ - #127
Merged
Merged
Conversation
Rewrite the app from Create React App + Almin + Office UI Fabric to a small, modern client-side stack: - Vite+ (vp) for dev, build, test (Vitest), lint (Oxlint), format (Oxfmt), type checking, staged-file checks and the pre-commit hook - React 19 function components and hooks; React Compiler with panicThreshold "all_errors" plus the React Compiler lint rules - StyleX for styles, with @stylexjs/eslint-plugin rules in Oxlint - Runtime dependencies reduced to react, react-dom, @stylexjs/stylex and markdown-it (removed almin, office-ui-fabric-react, localforage, lodash, date-fns, sanitize-html, combokeys, client-oauth2, and others) Architecture: Inoreader and GitHub Notifications are peers behind one `Source` contract (src/sources/source.ts). The framework-independent reader (src/app/reader.ts) implements LDR navigation, auto mark-read on departure through the newest loaded item, Shift+S skip, prefetch and filtering, and the UI never branches on a specific source. Settings forms are described as data so new sources need no UI changes. Compatibility: the Inoreader token in localStorage, the GitHub cache and token in IndexedDB, the localStorage CORS overrides, the keyboard shortcuts and the window.userScript API keep working. Tests: unit tests for the reader and both sources, and Playwright integration tests that drive the production build against fake Inoreader and GitHub APIs (e2e/fake-api). CI runs check, unit tests, build and the integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
The shortcut guard checked the reader's panel state, which is updated by the dialog's `close` event. A key pressed right after Escape could be dropped on slower machines. Check the DOM for an open dialog instead, and close the panel on `cancel` as well. Also wait for the token refresh request in the e2e test instead of the header message, which could already be shown before Refresh was clicked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
- Inoreader: keep the session when the token endpoint fails temporarily (5xx, proxy errors); only a rejected grant disconnects. Reuse a token another tab already refreshed instead of deleting it. - Inoreader: mark nothing read when no item was loaded; guard missing `published` so an item cannot break rendering with an invalid date. - Reader: ignore an older load that finishes after a newer revision, bound the item cache (LRU, 100 feeds), build category lists in O(n), and drop the unused view error field. - GitHub: apply the "already read" filter inside the serialized cache write, so a sync cannot bring back notifications marked read meanwhile; group items in O(n). - Keyboard: match the physical key on non-Latin layouts, and make userScript.registerKey replace default bindings like Combokeys did. - Carry over irodr 1.x preferences from IndexedDB `AppRepository`. - Refresh the relative-time clock when it restarts; show the action's own error in Sources; use StyleX longhands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
- Reader: keep a loaded feed when a newer load of it is still running, refresh the view after prefetch, show a source error only when this mark-read caused it, and show a feed the source dropped with 0 unread in both the list and the view. - Inoreader: the local "read through" override expires after 5 minutes and is dropped once Inoreader reports 0, so items marked unread elsewhere show up again. - GitHub: base last_read_at only on notifications with GitHub's own updated_at; keep repository order in a Set. - IndexedDB store: resolve writes when the transaction commits and reopen a connection the browser closed. - UI: close dialogs only on clicks that start and end on the backdrop, destroy a translator still being created when translate mode turns off, dispatch componentDidUpdate only on item or focus changes, let preference number fields be cleared while typing, set lazy loading before an image's src, and name the feed list class in CLASS. - Share shallowEqual from src/lib/equal.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
- Add a local Oxlint JS plugin, lib/oxlint-plugin-immutable (a pnpm workspace package), whose immutable/no-let rule bans `let`. Oxlint has no no-restricted-syntax, and prefer-const only flags a `let` that is never reassigned. - Turn on no-var, no-param-reassign, and no-restricted-properties for Object.assign. - Rewrite the existing code to satisfy them. Behavior is unchanged, verified by the unit and Playwright tests: - reassigned locals become const expressions or small helpers - counter loops become for...of over arrays and generators - state that changes over time lives in const holder objects - window.irodr gets a typed declaration instead of Object.assign - Translator is declared with `declare const` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Stage A of replacing classes with functional, immutable modules (conventions in docs/architecture.md, "Code style: functional and immutable"). - createStore, createEmitter, createKeyBindings and createTranslateMode replace the Store, Emitter, KeyBindings and TranslateMode classes, with the same semantics. New unit tests cover the store and emitter. - createFakeInoreader and createFakeGitHub replace the fake API classes. Tests change them through configure(), markUnread() and reset() instead of assigning fields, and the request log is an immutable list returned by log(). Control endpoints validate their JSON bodies. HTTP responses are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Stage B and C of replacing classes with functional, immutable modules. - Reader: createReader replaces the class. The reading state is an immutable ReaderModel with pure transitions (reader-model.ts), and a pure projection builds ReaderState while keeping the identities of unchanged parts (reader-view.ts). reader.ts keeps the side effects: loads, prefetch, mark-read and auto refresh. The public API is unchanged. A comparison of 30 recorded scenarios (rapid s/a, racing opens, failures, dropped feeds) against the old class matched exactly. - GitHub: createGitHubApi, createGitHubCache and createGitHubSource. Cache writes are pure (snapshot) => snapshot functions, run under the Web Lock after reloading the stored snapshot. - Inoreader: createInoreaderOAuth and createInoreaderSource, with the feed projection and read-through overrides as pure helpers. - Test doubles are factories too, and the new pure transitions have unit tests (170 unit tests now). - Lint: immutable/no-class bans classes except errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Moving with `s` scrolled the current feed to the top of the sidebar, under the sticky category header, so it was hidden. The feed list now has a scroll-padding-top of the header height (a new sizes.categoryHeader token). The header stays on one line with an ellipsis, so a long folder name can no longer make it taller than that padding. A Playwright test moves through 30 feeds in a short window with a long folder name. At each step it checks that nothing covers the current row. The test fails without the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
The functional rewrite used Map.groupBy and toSorted in the feed list projection and the GitHub source. build.target lowers syntax but adds no polyfills. The reader therefore threw at startup, and showed a blank page, in browsers that ran the previous code: Safari/iOS below 17.4, Chrome below 117 and Firefox below 119. Both places now group and sort with ES2022 built-ins and give the same output. To keep it that way, tsconfig.app.json uses the ES2022 lib, and the regression tests run startup code under withoutNewerBuiltIns, which removes the newer built-ins. A final review compared the old and new reader with about 8,800 random operation sequences and found no other difference. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
When Inoreader listed a subscription twice, both rows had the same feed ID. Both showed as current, and `s` moved from the feed to its own copy and stopped. irodr 1.x kept feeds in a repository keyed by ID, so the copy disappeared there. The Inoreader source now keeps the first entry for each stream ID, which gives the first place and category. The reader drops repeated feed IDs as well, so a source that lists a feed twice cannot stop navigation. uniqueBy (src/lib/unique.ts) keeps the first item per key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Browser support is now Baseline Widely available: features that Chrome, Edge, Firefox and Safari have supported for at least 2.5 years. This replaces the ES2022 restriction from the previous fix. - Build: use Vite's default target, baseline-widely-available, instead of es2024. - Lint: eslint-plugin-baseline-js runs as an Oxlint JS plugin on src/ (use-baseline, available: "widely", Web APIs and JS built-ins). It checks built-ins and Web APIs against web-features data, because nothing polyfills them. Tests and tools run in Node and are exempt. - Map.groupBy and toSorted are Baseline Widely available (since 2026-09-05 and 2026-01-04), so the feed list and the GitHub projection use them again, with the ES2024 lib. The ES2022 test helper is removed. - Top-level await is only Baseline Newly available (Safari 27), so main.tsx starts through an async function. - The Translator API is not Baseline. It is feature-detected in one helper, which is exempt from the rule, and translation stays optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Empty Client ID and secret inputs looked like missing input, although leaving them empty uses the default app. SourceSettings gains an optional `advanced` group that the Sources dialog renders as a collapsed <details>. The group is expanded when one of its fields has a saved value. The Inoreader client fields move into "Use your own Inoreader app". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
Blockquotes looked different from irodr 1.x. The rebuild had added its own blockquote (left border, gray text, no indent), table, and code styles, and used a 15px system-ui font. irodr 1.x rendered article HTML with the browser defaults. Compared through computed styles of the same content in both builds, article content now matches 1.x again: - sans-serif at the `medium` keyword size (16px). The keyword keeps the default 13px for monospace text in code and pre. - Browser-default blockquotes, tables and lists; 1.x's pre (line-height 1). - 24px titles, and title and "Original" links in 1.x's Fabric color (#0078d4) and font. The meta line is 16px and the footer 14px. - The footer has 1.x's SubscriptionContentsContainer-contentFooter class again, for user scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
…image - All dependency versions live in the default catalog of pnpm-workspace.yaml, pinned to the installed versions. package.json refers to them with "catalog:", and catalogMode: strict rejects versions outside the catalog. The Vite+ overrides (the vite alias and vitest) use the catalog too. - The e2e job runs in mcr.microsoft.com/playwright:v<version>-noble (--init --ipc=host --user=1001), which ships the browsers and their system dependencies, so CI no longer runs `playwright install --with-deps` on every run. A small job reads @playwright/test from the catalog with yq to pick the image, and fails if the version is not exact. Approach from https://tech.newmo.me/entry/playwright-ci-optimization Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
The Sources dialog links to GitHub's classic token form with the notifications scope selected, and with notifications and repo for private release bodies. SourceSettings gains `links` for such external pages. The form URL comes from the configured GitHub web URL, so GitHub Enterprise Server works too. GitHub documents pre-filled forms only for fine-grained tokens, and the Notifications API does not accept those. This classic URL is the one Composer builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
The React Compiler now runs through Oxc's Rust port: react({ compiler:
true }) in @vitejs/plugin-react with oxc-transform-react. Babel and
babel-plugin-react-compiler are no longer used. Oxlint's built-in React
Compiler rules (react/purity, react/refs, react/immutability, ...)
replace the eslint-plugin-react-hooks JS plugin. They reject components
the compiler would skip, which the Babel build's panicThreshold did
before. Oxlint has no rules for the compiler's config and gating
options, and this setup uses neither.
Removed: @babel/core, @rolldown/plugin-babel,
babel-plugin-react-compiler, eslint-plugin-react-hooks.
Added: oxc-transform-react.
The bundle keeps the same 27 memoized scopes. It differs from the Babel
build only in module order, and the production build takes 0.7 s
instead of 1.7 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E
berlysia
added a commit
to berlysia/dotfiles
that referenced
this pull request
Sep 27, 2026
Add a skill that defines how PR titles and bodies are written: a catalog of sections with the condition for writing each, a size guideline, how to state what was verified and by what means, and how to pick the language. Point the always-loaded rules and /create-pr at it. intent: make PR bodies answer the reviewer's questions with information the diff does not show, following azu/irodr#127 decision: skill holds the norms; rules get a one-line pointer so conversational "open a PR" requests also reach it decision: only overview and verification sections are mandatory; other sections are written when their condition holds decision: rename "Git Commit Standards" to "Git Commit & PR Standards" so the pointer is found by heading rejected: per-repository pull_request_template.md - cannot reach other people's repositories and cannot vary sections by PR size rejected: a mechanical hook checking PR bodies - only heading presence is machine-checkable, which does not measure whether the reviewer's questions are answered learned: two runs on the same diff disagreed on optional sections until the catalog said where borderline items go; residual optional-section variance remains and is accepted constraint: skill discovery from the rules line was verified with two sonnet runs that both invoked the skill without being told its name Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
berlysia
added a commit
to berlysia/dotfiles
that referenced
this pull request
Sep 27, 2026
Two runs over the same diff chose different optional sections, and the run that skipped a section also dropped the design reasons that would have gone there. Unverified items almost never reached 確認したこと. The skill now has the writer list the facts first (from every commit message, its action lines, and the diff), assign each fact to one section, and match every claimed behavior change against how it was observed. A section appears only when a fact is assigned to it. Changes seen only through automated tests are listed as 未確認 under 確認したこと. The テスト section is replaced by テスト基盤 (only for changes to the test setup itself); added tests become a short note on the verification line. decision: measure by whether fixed required facts appear, not by whether headings match decision: remove the テスト section; three rewordings of its rule could not stop test case enumeration, removing the place to put it did rejected: making section conditions diff-derivable thresholds (keeps headings stable but not the reasons, and loses judgement like azu/irodr#127) learned: over 4 runs per version on fb6f9e1 and dd44d27, all required facts 0-1/4 -> 4/4, 未確認 under 確認したこと 0/4 -> 4/4, no test enumeration 0/4 -> 3/4 (blind sonnet judge, quotes checked by hand) constraint: both test commits have rich action lines; commits without them and small PRs were not measured Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCRFWZisrdLbxod73opuCA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概要
irodr を作り直しました。Vite+(
vp)でビルドする、ブラウザだけで動く React 19 のアプリです。これまでの Create React App + Almin + Office UI Fabric の構成を置き換えています。LDR 風の画面とキー操作(j/k/s/aなど)は変えていません。Inoreader と GitHub Notifications は、どちらも同じ
Sourceインターフェースを実装する対等な「ソース」になりました。UI と Reader(状態管理の中心)は、どのソースのフィードかを知らずに動きます。新しいソースを足すときも、src/appとsrc/uiを変える必要はありません。コードは関数ベースで immutable な書き方に統一し、Lint で強制しています。主なルールは次の3つです。
createX()という関数で作る。技術スタック
vp dev/vp build/vp preview)vp check)vp test) + Playwrightreact,react-dom,@stylexjs/stylex,markdown-it)oxc-transform-react、@vitejs/plugin-reactのreact({ compiler: true }))。Babel は使っていません。react/purity、react/refsなど)。コンパイラが扱えないコンポーネントがあればvp checkが失敗します。@stylexjs/eslint-plugin、JS プラグイン)eslint-plugin-baseline-js、JS プラグイン)pnpm-workspace.yamlのcatalog:に集めて、すべて固定しています。catalogMode: strictなので、catalog にないバージョンは入りません。package.jsonの scripts にまとめ、AGENTS.mdにも書きました。人も AI も同じ手順で使えます。pnpm run check: 整形・Lint・型チェックpnpm run fix: 自動修正pnpm test: 単体テストpnpm run test:e2e: 結合テストベンチマーク(1.x との比較)
旧版(master のビルド)とこの PR のビルドを、同じ条件で Playwright(Chromium)から動かして比べました。
s・jはキーを 70 回押した中央値です。sで次のフィードの記事が出るまで(先読み済み)s(先読みなしの初回)jで次の記事へスクロールするまでjは中央値では同程度です。ただ、CPU を 4 倍遅くした条件の上位 10%(p90)は 27 → 35 ms で、この PR のほうが少し遅くなっています。構成
ui → app → sources → libの一方向だけです。capabilities(loadMore/unreadFilter/liveItems)SourceSettings)。折りたたむ任意の項目や、外部リンクもデータで表現できます。コードの書き方(関数ベース・immutable)
詳しくは docs/architecture.md の "Code style: functional and immutable" に書きました。
extends Errorだけは、instanceofによる判定とスタックトレースのために残しています。createX(options)で作ります。返すのはアロー関数を並べたオブジェクトなので、thisはありません。(state, input) => stateの形です。ソースやタイマーなしで単体テストできます。lib/oxlint-plugin-immutableと Oxlint の組み込みルールで、次を禁止しています。letvarObject.assignbaseline-widely-availableを対象にしています。eslint-plugin-baseline-jsが web-features のデータで組み込み関数と Web API を検査します。1.x から引き継いだもの(互換性)
localStorage["inoreader-token"])irodr-sourcesとirodr-source-credentialsで、1.x の localforage と同じ形式です。localStorageによる設定の上書き (REACT_APP_CORS_PROXY,REACT_APP_INOREADER_BASE_URL)window.userScriptとuserscript-initイベントSubscriptionContent::*イベントregisterKeyが既定のキー割り当てを置き換える挙動.SubscriptionContentsContainer-*,.SubscriptionListContainer-item,.ng-content)AppRepositoryから、初回起動時に引き継ぎます。挙動が変わったところ
sの連打: 読み込み中に押すと、読み込み中のフィードからさらに先へ進みます。読み込み中のフィードは点線の枠で示します。notificationsスコープ(非公開リポジトリ用にはrepoも)を選んだ状態で開きます。テスト
vp test)pnpm run test:e2e)vp build --mode e2eの成果物をvp previewで配信し、偽の Inoreader / GitHub API につなぎます。sで移動したときに、現在のフィードがカテゴリ見出しの裏に隠れないことe2e/fake-api/): 実際の API の挙動を真似ています。mark-all-as-readLinkヘッダーでのページ送り、X-Poll-Interval、既読化の 205 / 202 応答.github/workflows/test.yml): 「Check, unit test and build」と「Integration tests」の 2 つのジョブで動きます。mcr.microsoft.com/playwright:v<バージョン>-noble)の中で動かします。@playwright/testから読み出します。.vite-hooks/pre-commit): ステージしたファイルをvp check --fix/vp fmtで直し、単体テストを実行します。pnpm installのときにvp configが入れます。確認済みのこと
/cors-proxy/) が Inoreader まで中継すること。認証なしのリクエストに、Inoreader へ直接送ったときと同じ 403 が返りました。buildからdistに、ビルドコマンドはpnpm run buildに変えています。http://localhost:8888/) での OAuth ログイン: 既定の Inoreader アプリで確認しました。リダイレクト先は、1.x の開発時と同じhttp://localhost:8888/です。Vite+ はまだリリース候補版 (1.0.0-rc.0) です。React Compiler の Oxc 版(
oxc-transform-react)も、Oxc のドキュメントでは experimental の扱いです。🤖 Generated with Claude Code
https://claude.ai/code/session_01AFAqs7EHEVBKFYc8cT6N6E