Skip to content

Commit efa1038

Browse files
fix: resolve hydration mismatch on relative time display (#130) (#139)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 0196fd9 commit efa1038

3 files changed

Lines changed: 116 additions & 20 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { readFileSync } from "fs";
3+
import { join } from "path";
4+
import { formatRelativeDate } from "./relative-time";
5+
6+
describe("formatRelativeDate", () => {
7+
afterEach(() => {
8+
vi.useRealTimers();
9+
});
10+
11+
it('returns "just now" for dates less than 1 minute ago', () => {
12+
vi.useFakeTimers();
13+
vi.setSystemTime(new Date("2024-06-15T12:00:30Z"));
14+
expect(formatRelativeDate("2024-06-15T12:00:00Z")).toBe("just now");
15+
});
16+
17+
it("returns minutes ago for dates less than 1 hour ago", () => {
18+
vi.useFakeTimers();
19+
vi.setSystemTime(new Date("2024-06-15T12:47:00Z"));
20+
expect(formatRelativeDate("2024-06-15T12:00:00Z")).toBe("47m ago");
21+
});
22+
23+
it("returns hours ago for dates less than 24 hours ago", () => {
24+
vi.useFakeTimers();
25+
vi.setSystemTime(new Date("2024-06-15T15:00:00Z"));
26+
expect(formatRelativeDate("2024-06-15T12:00:00Z")).toBe("3h ago");
27+
});
28+
29+
it("returns days ago for dates less than 7 days ago", () => {
30+
vi.useFakeTimers();
31+
vi.setSystemTime(new Date("2024-06-18T12:00:00Z"));
32+
expect(formatRelativeDate("2024-06-15T12:00:00Z")).toBe("3d ago");
33+
});
34+
35+
it("returns formatted date for dates 7+ days ago", () => {
36+
vi.useFakeTimers();
37+
vi.setSystemTime(new Date("2024-06-25T12:00:00Z"));
38+
expect(formatRelativeDate("2024-06-15T12:00:00Z")).toBe("Jun 15");
39+
});
40+
});
41+
42+
describe("RelativeTime hydration safety", () => {
43+
it("uses suppressHydrationWarning to prevent hydration mismatch errors", () => {
44+
const source = readFileSync(
45+
join(__dirname, "relative-time.tsx"),
46+
"utf-8",
47+
);
48+
49+
// The component must use suppressHydrationWarning on the span element
50+
// so React ignores the text content difference between SSR and client.
51+
expect(source).toContain("suppressHydrationWarning");
52+
});
53+
54+
it("uses useEffect with setInterval to keep the display current", () => {
55+
const source = readFileSync(
56+
join(__dirname, "relative-time.tsx"),
57+
"utf-8",
58+
);
59+
60+
expect(source).toMatch(/useEffect\(/);
61+
expect(source).toMatch(/setInterval\(/);
62+
});
63+
});

src/components/relative-time.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"use client";
2+
3+
import { useEffect, useReducer } from "react";
4+
5+
interface RelativeTimeProps {
6+
dateStr: string;
7+
className?: string;
8+
}
9+
10+
/**
11+
* Renders a relative time string (e.g. "5m ago") with suppressHydrationWarning
12+
* to avoid React hydration mismatches. The server and client may compute
13+
* slightly different values because time advances between SSR and hydration.
14+
* The interval keeps the display current after mount.
15+
*/
16+
export function RelativeTime({ dateStr, className }: RelativeTimeProps) {
17+
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
18+
19+
useEffect(() => {
20+
const interval = setInterval(forceUpdate, 60_000);
21+
return () => clearInterval(interval);
22+
}, [dateStr]);
23+
24+
return (
25+
<span className={className} suppressHydrationWarning>
26+
{formatRelativeDate(dateStr)}
27+
</span>
28+
);
29+
}
30+
31+
export function formatRelativeDate(dateStr: string): string {
32+
const date = new Date(dateStr);
33+
const now = new Date();
34+
const diffMs = now.getTime() - date.getTime();
35+
const diffMins = Math.floor(diffMs / 60000);
36+
const diffHours = Math.floor(diffMs / 3600000);
37+
const diffDays = Math.floor(diffMs / 86400000);
38+
39+
if (diffMins < 1) return "just now";
40+
if (diffMins < 60) return `${diffMins}m ago`;
41+
if (diffHours < 24) return `${diffHours}h ago`;
42+
if (diffDays < 7) return `${diffDays}d ago`;
43+
44+
return date.toLocaleDateString("en-US", {
45+
month: "short",
46+
day: "numeric",
47+
});
48+
}

src/components/workspace-home.tsx

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { toast } from "sonner";
66
import { createClient } from "@/lib/supabase/client";
77
import { captureSupabaseError } from "@/lib/sentry";
88
import { Button } from "@/components/ui/button";
9+
import { RelativeTime } from "@/components/relative-time";
910

1011
interface WorkspaceHomeProps {
1112
workspace: { id: string; name: string; slug: string };
@@ -95,31 +96,15 @@ export function WorkspaceHome({
9596
<span className="flex-1 truncate">
9697
{page.title || "Untitled"}
9798
</span>
98-
<span className="text-xs text-muted-foreground">
99-
{formatRelativeDate(page.updated_at)}
100-
</span>
99+
<RelativeTime
100+
dateStr={page.updated_at}
101+
className="text-xs text-muted-foreground"
102+
/>
101103
</button>
102104
))}
103105
</div>
104106
</div>
105107
);
106108
}
107109

108-
function formatRelativeDate(dateStr: string): string {
109-
const date = new Date(dateStr);
110-
const now = new Date();
111-
const diffMs = now.getTime() - date.getTime();
112-
const diffMins = Math.floor(diffMs / 60000);
113-
const diffHours = Math.floor(diffMs / 3600000);
114-
const diffDays = Math.floor(diffMs / 86400000);
115-
116-
if (diffMins < 1) return "just now";
117-
if (diffMins < 60) return `${diffMins}m ago`;
118-
if (diffHours < 24) return `${diffHours}h ago`;
119-
if (diffDays < 7) return `${diffDays}d ago`;
120110

121-
return date.toLocaleDateString("en-US", {
122-
month: "short",
123-
day: "numeric",
124-
});
125-
}

0 commit comments

Comments
 (0)