diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ec7302..1e40fe1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,28 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
+## 0.2.1 — 2026-05-26
+
+**Patch — no operator action required.** Rebuild the frontend and
+restart per the routine deploy steps; no `.env` changes, no schema
+changes, no behavior changes a deployment would notice in steady
+state.
+
+### Fixed
+
+- **PR view blank page (React #310).** `frontend/src/components/PRView.jsx`
+ declared its `threadsByKind` `useMemo` *after* the early-return
+ guard for the loading state (`if (!pr) return …`). On first mount
+ `pr` was null so the early return fired and 14 hooks were called;
+ on the second render `pr` was populated and execution reached the
+ `useMemo`, calling 15 hooks — violating the Rules of Hooks and
+ unmounting the page subtree (blank screen). The `useMemo` is now
+ declared above the early returns with optional-chaining on `pr`,
+ keeping the hook count stable between the loading and loaded
+ renders. Pre-existing bug in the post-Contribute-rewrite PR view;
+ surfaced in production by 0.2.0's graduation merge race fix
+ enabling the workflow that opens this code path.
+
## 0.2.0 — 2026-05-26
**Breaking config change.** The frontend now requires
diff --git a/VERSION b/VERSION
index 0ea3a94..0c62199 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.0
+0.2.1
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3f03771..7a70921 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
- "version": "0.1.0",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
- "version": "0.1.0",
+ "version": "0.2.1",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
diff --git a/frontend/package.json b/frontend/package.json
index 565c486..fa730fe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
- "version": "0.2.0",
+ "version": "0.2.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/components/PRView.jsx b/frontend/src/components/PRView.jsx
index 15363d9..3c11457 100644
--- a/frontend/src/components/PRView.jsx
+++ b/frontend/src/components/PRView.jsx
@@ -143,6 +143,19 @@ export default function PRView({ viewer }) {
}
}, [slug, prNumber, reviewText, reviewDraft, refresh])
+ // §10.6: split messages by thread kind for the visual distinction
+ // §10.4 requires. Within each kind, sort by id for chronological
+ // order. Declared above the early returns below so the hook count
+ // stays stable between the loading render (pr == null) and the
+ // loaded render (Rules of Hooks — React #310 otherwise).
+ const threadsByKind = useMemo(() => {
+ const out = { chat: [], review: [], flag: [] }
+ for (const t of pr?.threads || []) {
+ out[t.thread_kind]?.push(t)
+ }
+ return out
+ }, [pr?.threads])
+
// ── render ─────────────────────────────────────────────────────
if (error) return Error: {error}
if (!pr) return Loading PR…
@@ -154,17 +167,6 @@ export default function PRView({ viewer }) {
const supersededBy = pr.superseded_by_pr_number
const supersedes = pr.supersedes_pr_number
- // §10.6: split messages by thread kind for the visual distinction
- // §10.4 requires. Within each kind, sort by id for chronological
- // order.
- const threadsByKind = useMemo(() => {
- const out = { chat: [], review: [], flag: [] }
- for (const t of pr.threads || []) {
- out[t.thread_kind]?.push(t)
- }
- return out
- }, [pr.threads])
-
const seenSha = pr.seen?.last_seen_commit_sha
const seenMsgId = pr.seen?.last_seen_message_id || 0