Fix React #418 hydration abort from clock-derived relative times#169
Conversation
AP-122 Fix React #418 hydration error on the ephemeral claim page
OutcomeThe authenticated ephemeral claim page ( Evidence (observed live on preview)Deployed It fires independently of Turnstile state (reproduced before and after the Turnstile widget started passing), so it is not related to the Turnstile fix — it is a pre-existing defect on the surface AP-105 shipped. Likely cause
#418 is a server/client text divergence. Prime suspects to confirm:
In scope
Out of scope
Acceptance criteria
Required checks
DependenciesSurfaced during post-merge preview verification of AP-105 (claim endpoint). No blocker; AP-105 is merged. |
📝 WalkthroughWalkthroughReplace render-time relative formatting with a hydration-safe RelativeTime component, add formatAbsoluteTime and injectable formatRelativeTime, introduce useHydrated, and update UI components, routes, and tests to avoid SSR/client content mismatches. ChangesRelative Time Hydration Fix
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
agent-paste PR preview is ready. API: https://agent-paste-api-pr-169.isaac-a46.workers.dev |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/web/test/RelativeTime.test.tsx (1)
12-19: ⚡ Quick winThis test isn't exercising the hydrated client path.
Lines 13-15 call
renderToString()twice, so both sides are using the server renderer and neither side runs the client hydration flow. That makes this weaker than the test name suggests for guarding the#418regression. I'd switch this to a realhydrateRootassertion (or equivalent DOM hydration check) so the test actually covers server markup vs first hydrated client render.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/test/RelativeTime.test.tsx` around lines 12 - 19, The test currently calls renderToString twice so it never exercises client hydration; change it to render the server markup with renderToString(<RelativeTime value={ISO} />), inject that HTML into a DOM container, then hydrate the client into that container using hydrateRoot (from react-dom/client) or an equivalent hydrate method with <RelativeTime value={ISO} /> and assert that the container.innerHTML (or rendered DOM) after hydration equals the original server markup and still does not contain relative phrases (no /ago|in /) and does contain the absolute year (/2026/); update imports/setup in RelativeTime.test.tsx accordingly and replace the second renderToString call with the hydration flow so the test validates server vs first-client paint behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/components/ui/RelativeTime.tsx`:
- Around line 16-20: The component currently snapshots the relative label once
in the useEffect (setting state with setRelative(formatRelativeTime(value))) and
never updates it, causing timestamps to freeze; change the effect in
RelativeTime (the useEffect that references value, setRelative, and
formatRelativeTime) to set up a timer (setInterval) that recomputes
setRelative(formatRelativeTime(value)) at an appropriate cadence (e.g., every
second or every minute depending on precision) and return a cleanup that clears
the interval to avoid leaks so the label updates live after hydration.
---
Nitpick comments:
In `@apps/web/test/RelativeTime.test.tsx`:
- Around line 12-19: The test currently calls renderToString twice so it never
exercises client hydration; change it to render the server markup with
renderToString(<RelativeTime value={ISO} />), inject that HTML into a DOM
container, then hydrate the client into that container using hydrateRoot (from
react-dom/client) or an equivalent hydrate method with <RelativeTime value={ISO}
/> and assert that the container.innerHTML (or rendered DOM) after hydration
equals the original server markup and still does not contain relative phrases
(no /ago|in /) and does contain the absolute year (/2026/); update imports/setup
in RelativeTime.test.tsx accordingly and replace the second renderToString call
with the hydration flow so the test validates server vs first-client paint
behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1e5981eb-cb69-4727-b3c0-fac6af3d2fa7
📒 Files selected for processing (13)
apps/web/src/components/admin/LockdownList.tsxapps/web/src/components/admin/OperatorEventsPanel.tsxapps/web/src/components/dashboard/RecentArtifacts.tsxapps/web/src/components/dashboard/RecentAudit.tsxapps/web/src/components/keys/KeysTable.tsxapps/web/src/components/ui/RelativeTime.tsxapps/web/src/lib/format.tsapps/web/src/lib/use-hydrated.tsapps/web/src/routes/_authed.artifacts.$artifactId.tsxapps/web/src/routes/_authed.artifacts.index.tsxapps/web/src/routes/_authed.audit.tsxapps/web/test/RelativeTime.test.tsxapps/web/test/format.test.ts
…hydration abort
formatRelativeTime() read Date.now() during render, so SSR produced a
different relative string than the client's first hydration paint
("2 minutes ago" vs "3 minutes ago"). React treated the text mismatch as
hydration error #418, discarded the server-rendered tree, and never
attached event handlers — leaving every button in the app dead. This is
why creating an API key (and any other action) did nothing on the
deployed dashboard/keys pages.
Add a <RelativeTime> component that renders a deterministic, UTC-pinned
absolute timestamp on the server and first client paint, then upgrades to
a live relative string after mount. Route all 8 SSR call sites (dashboard,
keys, artifacts list/detail, audit, admin lockdowns/events) through it,
and gate the keys table's Date.now() expiry check behind useHydrated().
Issue: AP-122
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI Validate failed on the branch-coverage gate (79.99% vs 80%). The uncovered branch was RelativeTime's invalid-date path in toIso(). Adding that test surfaced a real latent crash: formatRelativeTime() fed an unparseable value throws RangeError from Intl.RelativeTimeFormat. Guard it to return "" (matching formatAbsoluteTime), and cover the keyState not-yet-expired branch in KeysTable. Branch coverage back to 80.04%. Issue: AP-122 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
agent-paste PR preview is ready. API: https://agent-paste-api-pr-169.isaac-a46.workers.dev |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
e681519 to
137f217
Compare
|
agent-paste PR preview is ready. API: https://agent-paste-api-pr-169.isaac-a46.workers.dev |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
apps/web/src/components/ui/RelativeTime.tsx (1)
16-20:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep the relative label live after hydration.
Line 19 snapshots the relative text once, so mounted labels can stay stuck at
"just now"/"2m ago"indefinitely, and a prop change can briefly keep showing the old label until the effect reruns. This needs a timer-driven refresh (or shared ticker) with cleanup, not a one-time post-mount write.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/ui/RelativeTime.tsx` around lines 16 - 20, The RelativeTime component currently snapshots the label once in the useEffect (setRelative(formatRelativeTime(value))) causing stale text; change the effect to set the initial relative value immediately and then start a timer (or subscribe to a shared ticker) that updates setRelative(formatRelativeTime(value)) periodically (e.g., every 30s or appropriate granularity) and ensure you clear the interval/subscription in the cleanup function; make the effect depend on value so the timer resets when value changes and avoid memory leaks by clearing on unmount.
🧹 Nitpick comments (1)
apps/web/test/KeysTable.test.tsx (1)
90-90: 💤 Low valueConsider a more precise assertion for Active badge count.
With the test fixture now containing exactly two Active keys (null expiry + future expiry), using
toHaveLength(2)would catch regressions more reliably than> 0.♻️ Suggested change
- expect(screen.getAllByText("Active").length).toBeGreaterThan(0); + expect(screen.getAllByText("Active")).toHaveLength(2);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/test/KeysTable.test.tsx` at line 90, Replace the imprecise assertion that checks Active badge count with an exact length check: change the assertion that uses screen.getAllByText("Active").length with toBeGreaterThan(0) to assert the exact expected count (toHaveLength(2)) so the test verifies there are exactly two "Active" badges given the fixture (null expiry + future expiry); update the assertion in KeysTable.test.tsx where screen.getAllByText("Active") is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/lib/format.ts`:
- Around line 39-47: formatAbsoluteTime currently fixes locale and timeZone but
leaves hour formatting and the timeZoneName label to runtime Intl defaults,
causing non-deterministic server vs browser output; update formatAbsoluteTime to
explicitly set hour12 (or hourCycle) to a concrete value (e.g., hour12: false
for 24-hour) to ensure byte-stable output, and if you need fully deterministic
labels across environments, produce the string by using
Intl.DateTimeFormat.prototype.formatToParts and assemble the parts yourself
rather than relying on the final formatted string.
---
Duplicate comments:
In `@apps/web/src/components/ui/RelativeTime.tsx`:
- Around line 16-20: The RelativeTime component currently snapshots the label
once in the useEffect (setRelative(formatRelativeTime(value))) causing stale
text; change the effect to set the initial relative value immediately and then
start a timer (or subscribe to a shared ticker) that updates
setRelative(formatRelativeTime(value)) periodically (e.g., every 30s or
appropriate granularity) and ensure you clear the interval/subscription in the
cleanup function; make the effect depend on value so the timer resets when value
changes and avoid memory leaks by clearing on unmount.
---
Nitpick comments:
In `@apps/web/test/KeysTable.test.tsx`:
- Line 90: Replace the imprecise assertion that checks Active badge count with
an exact length check: change the assertion that uses
screen.getAllByText("Active").length with toBeGreaterThan(0) to assert the exact
expected count (toHaveLength(2)) so the test verifies there are exactly two
"Active" badges given the fixture (null expiry + future expiry); update the
assertion in KeysTable.test.tsx where screen.getAllByText("Active") is used.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 4bc93fd5-8d32-49b4-9c0c-952f415c1c6a
📒 Files selected for processing (14)
apps/web/src/components/admin/LockdownList.tsxapps/web/src/components/admin/OperatorEventsPanel.tsxapps/web/src/components/dashboard/RecentArtifacts.tsxapps/web/src/components/dashboard/RecentAudit.tsxapps/web/src/components/keys/KeysTable.tsxapps/web/src/components/ui/RelativeTime.tsxapps/web/src/lib/format.tsapps/web/src/lib/use-hydrated.tsapps/web/src/routes/_authed.artifacts.$artifactId.tsxapps/web/src/routes/_authed.artifacts.index.tsxapps/web/src/routes/_authed.audit.tsxapps/web/test/KeysTable.test.tsxapps/web/test/RelativeTime.test.tsxapps/web/test/format.test.ts
…t output (AP-122) formatAbsoluteTime left hour12/hourCycle to the runtime Intl default, which Cloudflare Workers SSR and the browser can resolve differently for the same instant (09:30 vs 09:30 AM). Since AP-122's entire purpose is hydration-stable time rendering, that non-determinism could reintroduce the React #418 mismatch the PR fixes. Pin hour12:false so the hour format is identical across runtimes. Add byte-lock tests asserting the exact UTC string for a morning and an afternoon instant so a future runtime-default drift is caught here instead of as a hydration abort in production. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
agent-paste PR preview is ready. API: https://agent-paste-api-pr-169.isaac-a46.workers.dev |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/web/src/components/ui/RelativeTime.tsx (1)
18-20: ⚡ Quick winConsider adding a timer to keep relative labels fresh.
The component currently snapshots the relative text once after mount (line 19) and updates only when
valuechanges. If a user stays on the page, "2 minutes ago" will never update to "3 minutes ago" as time passes. While the hydration fix is complete, consider setting up asetIntervalin theuseEffectto periodically recompute the relative string for better UX.⏱️ Suggested enhancement for live-updating labels
useEffect(() => { setRelative(formatRelativeTime(value)); + const interval = setInterval(() => { + setRelative(formatRelativeTime(value)); + }, 60_000); // Refresh every minute + return () => clearInterval(interval); }, [value]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/ui/RelativeTime.tsx` around lines 18 - 20, The effect that sets relative time (useEffect updating setRelative(formatRelativeTime(value))) only runs on value changes; add a setInterval inside the same useEffect to recompute and call setRelative(formatRelativeTime(value)) periodically (e.g., every 30–60s) so labels stay fresh, and return a cleanup that clears the interval to avoid leaks; keep value in the dependency array so the label updates immediately when value changes and the interval uses the latest value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/web/src/components/ui/RelativeTime.tsx`:
- Around line 18-20: The effect that sets relative time (useEffect updating
setRelative(formatRelativeTime(value))) only runs on value changes; add a
setInterval inside the same useEffect to recompute and call
setRelative(formatRelativeTime(value)) periodically (e.g., every 30–60s) so
labels stay fresh, and return a cleanup that clears the interval to avoid leaks;
keep value in the dependency array so the label updates immediately when value
changes and the interval uses the latest value.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 015b743f-94b3-42d1-a464-c23e2d824e8e
📒 Files selected for processing (14)
apps/web/src/components/admin/LockdownList.tsxapps/web/src/components/admin/OperatorEventsPanel.tsxapps/web/src/components/dashboard/RecentArtifacts.tsxapps/web/src/components/dashboard/RecentAudit.tsxapps/web/src/components/keys/KeysTable.tsxapps/web/src/components/ui/RelativeTime.tsxapps/web/src/lib/format.tsapps/web/src/lib/use-hydrated.tsapps/web/src/routes/_authed.artifacts.$artifactId.tsxapps/web/src/routes/_authed.artifacts.index.tsxapps/web/src/routes/_authed.audit.tsxapps/web/test/KeysTable.test.tsxapps/web/test/RelativeTime.test.tsxapps/web/test/format.test.ts
|
agent-paste PR preview resources were cleaned up. The shared Preview GitHub Environment is retained for future preview deploys. |
Summary
Every authenticated page threw React hydration error #418 and the entire app was non-interactive — clicking "Create key", revoking, navigating, anything, did nothing. Reproduced live on deployed preview and production (
/keys,/dashboard).Root cause:
formatRelativeTime()calledDate.now()at render time. The SSR server (Cloudflare Worker, UTC) renders a relative string like "2 minutes ago"; by the time the browser hydrates, the clock has advanced and it computes a different string. React treats the text mismatch as error #418, throws away the server-rendered tree, and never attaches event handlers — so the page paints but no button works.This was rendered at 8 SSR call sites (dashboard, keys, artifacts list + detail, audit, admin lockdowns/events), so a mismatch on the landing dashboard alone killed interactivity everywhere.
Changes
<RelativeTime>component: renders a deterministic, UTC-pinned absolute timestamp on the server and first client paint (byte-identical both sides), then upgrades to a live relative string after mount;suppressHydrationWarning+<time dateTime>for a11y.formatAbsoluteTime()— clock- and timezone-independent formatter for the SSR/first-paint value.formatRelativeTime(input, now?)—nowis now injectable so a single timestamp can be pinned.useHydrated()hook — gates the keys table'sDate.now()expiry check so the Active/Expired badge doesn't read the wall clock until after mount.<RelativeTime>.RelativeTime.test.tsx(asserts identical server/clientrenderToStringoutput — direct feat(api): runtime-tunable ephemeral provision cap via KV (AP-174) #418 regression guard) and newformatcases for determinism.Risk: LOW
titleis now human-readable instead of raw ISO.Test plan
pnpm verify— full gate green (lint, typecheck, test, openapi:check, db:check, knip, docs)pnpm --filter @agent-paste/web test— 15 new format/RelativeTime tests passpnpm build— web bundle builds/dashboardand/keys, confirm zero console errors (no feat(api): runtime-tunable ephemeral provision cap via KV (AP-174) #418), and that "Create key" works/claimand/claim?token=…remain clean (already fixed by feat(web): Claim Token redemption UX for ephemeral artifacts (AP-108) #165; verify no regression)Issue: AP-122
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Testing