Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/community-hydration-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Community reaction and hydration fix — revision 1

Scope: React only, latest main ae61e82; preserve existing XP policy and APIs.

1. Personalized/refetched post liked state, count and bookmark state replace stale SSR values. Pending optimistic writes are not overwritten by incoming props. Prevent duplicate clicks while each request is pending; failed writes restore current authoritative props.
2. Remove non-deterministic date rendering from the SSR/initial hydration pass. Post timestamps have no offset (observed post25 createdAt 2026-11-10 00:37:53); server HTML says 1일 전 and browser says 2일 전. Render a deterministic date label before hydration and relative label after hydration; document/normalize timezone only after confirming source contract. Cover different server/client clocks and timezone.
3. Regression tests: personalized rerender, refetch count, pending race, failure, duplicate clicks, hydration recoverable error detection across changed clocks. Desktop/mobile rendered validation, full npm run verify, independent review and all mandatory CI before authorized deployment.
4. No new dependency, no suppression of hydration warnings, no permanent production activity mutation.

## Confirmed source contract and validation

Community PostDetailResponse and PostSummaryResponse serialize LocalDateTime using yyyy-MM-dd HH:mm:ss; docker-compose-prod.yml sets TZ=Asia/Seoul. Offset-free normal community dates therefore receive +09:00; explicit ISO offsets are retained. CommunityTime is used only in normal post/comment/popular-post views, not outfit UTC timestamps.

Before the fix, hydrateRoot regression reported a recoverable text mismatch at the timestamp link; personalized rerender and pending-write tests also failed. After the fix all 19 focused tests pass. Production read-only baseline on post26 additionally confirmed API isLiked=true/count2 while the button aria-pressed=false. No reaction was changed.

Browser localhost startup had been denied in the preceding task, so no alternate launcher was attempted. Pre-deployment rendering validation uses React renderToString/hydrateRoot; desktop/mobile actual-session checks follow the authorized official deployment.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
"version": "1.0.24",
"version": "1.0.25",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-production-workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test("production Compose requires the immutable image reference", () => {
test("package and lockfile agree on the next release version", () => {
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.24");
assert.equal(pkg.version, "1.0.25");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
});
2 changes: 1 addition & 1 deletion scripts/workflow-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test("release version and immutable image contract are fixed", () => {
);
const pkg = JSON.parse(read("package.json"));
const lock = JSON.parse(read("package-lock.json"));
assert.equal(pkg.version, "1.0.24");
assert.equal(pkg.version, "1.0.25");
assert.equal(lock.version, pkg.version);
assert.equal(lock.packages[""].version, pkg.version);
assert.match(
Expand Down
16 changes: 2 additions & 14 deletions src/components/page/community/CommentItem.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"use client";

import { formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale";
import {
Check,
Flag,
Expand Down Expand Up @@ -36,6 +34,7 @@ import { clientAxios } from "@/lib/api/clients";
import { CommentPageResponseItem } from "@/types/community";

import CommentForm from "./CommentForm";
import CommunityTime from "./CommunityTime";
import ReportDialog from "./ReportDialog";

interface CommentItemProps {
Expand Down Expand Up @@ -196,17 +195,6 @@ export default function CommentItem({
}
}, [comment.id, onRefetch]);

const formatDate = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), {
addSuffix: true,
locale: ko,
});
} catch {
return "";
}
};

// 삭제되거나 차단된 댓글 표시
if (comment.isDeleted || comment.isBlocked) {
return (
Expand Down Expand Up @@ -278,7 +266,7 @@ export default function CommentItem({
</Link>
{comment.createdAt && (
<span className="text-xs leading-[18px] text-muted-foreground">
{formatDate(comment.createdAt)}
<CommunityTime value={comment.createdAt} />
</span>
)}
</div>
Expand Down
36 changes: 36 additions & 0 deletions src/components/page/community/CommunityTime.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { renderToString } from "react-dom/server";

import CommunityTime from "./CommunityTime";

describe("CommunityTime", () => {
afterEach(() => jest.restoreAllMocks());

it("uses deterministic server text and interprets Community wall time as KST", () => {
jest.spyOn(Date, "now").mockReturnValue(Date.parse("2026-11-11T10:47:21Z"));
const value = "2026-11-10 00:37:53";
expect(renderToString(<CommunityTime value={value} />)).toBe(
'<time dateTime="2026-11-10T00:37:53+09:00">2026-11-10</time>',
);
render(<CommunityTime value={value} />);
expect(screen.getByText("2일 전")).toHaveAttribute(
"datetime",
"2026-11-10T00:37:53+09:00",
);
});

it.each(["2026-11-10T00:37:53Z", "2026-11-10T00:37:53+03:00"])(
"preserves explicit offsets: %s",
(value) => {
const { container } = render(<CommunityTime value={value} />);
expect(container.querySelector("time")).toHaveAttribute(
"datetime",
value,
);
},
);

it("does not throw on an unavailable timestamp", () => {
render(<CommunityTime value="" />);
});
});
32 changes: 32 additions & 0 deletions src/components/page/community/CommunityTime.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale";
import { useSyncExternalStore } from "react";

const subscribe = () => () => {};
const clientSnapshot = () => true;
const serverSnapshot = () => false;

/** The server and first client render must not depend on a clock or timezone. */
export default function CommunityTime({ value }: { value: string }) {
const hydrated = useSyncExternalStore(
subscribe,
clientSnapshot,
serverSnapshot,
);
// Community's LocalDateTime fields use the Asia/Seoul wall clock. Keep
// explicit ISO offsets intact and never interpret offset-free values locally.
const iso = value.replace(" ", "T");
const timestamp = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(iso)
? `${iso}+09:00`
: iso;
let label = value.slice(0, 10);
if (hydrated) {
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) {
label = formatDistanceToNow(date, { addSuffix: true, locale: ko });
}
}
return <time dateTime={timestamp}>{label}</time>;
}
9 changes: 3 additions & 6 deletions src/components/page/community/PopularPostsHighlight.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { formatDistanceToNow } from "date-fns";
import { ko } from "date-fns/locale";
import { ChevronRight, Eye, Flame, Heart, MessageCircle } from "lucide-react";
import Link from "next/link";

import { useInfinitePosts } from "@/hooks/useInfinitePosts";

import CommunityTime from "./CommunityTime";

interface PopularPostsHighlightProps {
boardId?: number;
}
Expand Down Expand Up @@ -97,10 +97,7 @@ export function PopularPostsContent({
{post.commentCount.toLocaleString()}
</span>
<span className="hidden md:inline">
{formatDistanceToNow(new Date(post.createdAt), {
addSuffix: true,
locale: ko,
})}
<CommunityTime value={post.createdAt} />
</span>
</div>
</div>
Expand Down
124 changes: 123 additions & 1 deletion src/components/page/community/PostCard.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";

import {
normalizePostSummary,
Expand Down Expand Up @@ -50,6 +58,120 @@ const post: PostSummary = {
};

describe("PostCard", () => {
const card = (item: PostSummary) => (
<QueryClientProvider client={new QueryClient()}>
<PostCard post={item} />
</QueryClientProvider>
);

it("reconciles personalized reactions and later count updates", () => {
const view = render(card(post));
view.rerender(
card({ ...post, liked: true, likeCount: 8, bookmarked: true }),
);
expect(screen.getByRole("button", { name: "좋아요" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: "좋아요" })).toHaveTextContent(
"8",
);
expect(screen.getByRole("button", { name: "즐겨찾기" })).toHaveAttribute(
"aria-pressed",
"true",
);
view.rerender(card({ ...post, likeCount: 9 }));
expect(screen.getByRole("button", { name: "좋아요" })).toHaveTextContent(
"9",
);
expect(screen.getByRole("button", { name: "좋아요" })).toHaveAttribute(
"aria-pressed",
"false",
);
});

it("keeps a pending optimistic reaction, blocks duplicate clicks and rolls back to fresh props", async () => {
let reject!: (error: Error) => void;
(clientAxios.post as jest.Mock).mockImplementationOnce(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
const view = render(card(post));
const like = screen.getByRole("button", { name: "좋아요" });
fireEvent.click(like);
expect(like).toBeDisabled();
fireEvent.click(like);
view.rerender(card({ ...post, likeCount: 10 }));
expect(like).toHaveAttribute("aria-pressed", "true");
expect(like).toHaveTextContent("3");
await act(async () => reject(new Error("failed")));
expect(like).toHaveAttribute("aria-pressed", "false");
expect(like).toHaveTextContent("10");
expect(like).not.toBeDisabled();
});

it("restores the last successful local state when a subsequent toggle fails before refresh", async () => {
(clientAxios.post as jest.Mock)
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("failed"));
render(card(post));
const like = screen.getByRole("button", { name: "좋아요" });
fireEvent.click(like);
await waitFor(() => expect(like).not.toBeDisabled());
expect(like).toHaveAttribute("aria-pressed", "true");
fireEvent.click(like);
await waitFor(() => expect(like).not.toBeDisabled());
expect(like).toHaveAttribute("aria-pressed", "true");
expect(like).toHaveTextContent("3");
});

it("hydrates the timestamp without recoverable errors across server/client clocks", async () => {
const clock = jest
.spyOn(Date, "now")
.mockReturnValue(Date.parse("2026-11-11T00:00:00Z"));
const container = document.createElement("div");
container.innerHTML = renderToString(
card({ ...post, createdAt: "2026-11-10 00:37:53" }),
);
document.body.append(container);
clock.mockReturnValue(Date.parse("2026-11-12T12:00:00Z"));
const onRecoverableError = jest.fn();
let root!: ReturnType<typeof hydrateRoot>;
await act(async () => {
root = hydrateRoot(
container,
card({ ...post, createdAt: "2026-11-10 00:37:53" }),
{ onRecoverableError },
);
});
expect(onRecoverableError).not.toHaveBeenCalled();
await act(async () => root.unmount());
container.remove();
clock.mockRestore();
});

it("retains a successful like across stale in-flight props, then accepts the authoritative refresh", async () => {
let resolve!: (value: object) => void;
(clientAxios.post as jest.Mock).mockImplementationOnce(
() =>
new Promise((done) => {
resolve = done;
}),
);
const view = render(card(post));
const like = screen.getByRole("button", { name: "좋아요" });
fireEvent.click(like);
view.rerender(card({ ...post, likeCount: 10 }));
await act(async () => resolve({}));
expect(like).toHaveAttribute("aria-pressed", "true");
expect(like).toHaveTextContent("11");
view.rerender(card({ ...post, liked: true, likeCount: 12 }));
expect(like).toHaveTextContent("12");
view.rerender(card({ ...post, liked: false, likeCount: 11 }));
expect(like).toHaveAttribute("aria-pressed", "false");
});
const renderCard = (item: PostSummary = post) =>
render(
<QueryClientProvider client={new QueryClient()}>
Expand Down
Loading
Loading