From ff9abcc96c7c6ff42d662baceb0aa6ef2560e971 Mon Sep 17 00:00:00 2001
From: Lee Sanghyun <59863112+dev-ant@users.noreply.github.com>
Date: Wed, 11 Nov 2026 19:58:37 +0900
Subject: [PATCH] fix: synchronize community reactions and stabilize hydration
---
docs/community-hydration-fix.md | 16 +++
package-lock.json | 4 +-
package.json | 2 +-
scripts/test-production-workflow.mjs | 2 +-
scripts/workflow-contract.test.mjs | 2 +-
src/components/page/community/CommentItem.tsx | 16 +--
.../page/community/CommunityTime.test.tsx | 36 +++++
.../page/community/CommunityTime.tsx | 32 +++++
.../page/community/PopularPostsHighlight.tsx | 9 +-
.../page/community/PostCard.test.tsx | 124 +++++++++++++++++-
src/components/page/community/PostCard.tsx | 87 +++++++++---
11 files changed, 289 insertions(+), 41 deletions(-)
create mode 100644 docs/community-hydration-fix.md
create mode 100644 src/components/page/community/CommunityTime.test.tsx
create mode 100644 src/components/page/community/CommunityTime.tsx
diff --git a/docs/community-hydration-fix.md b/docs/community-hydration-fix.md
new file mode 100644
index 0000000..62ab86c
--- /dev/null
+++ b/docs/community-hydration-fix.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index 42256f5..ea1cc2d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devnogi-front",
- "version": "1.0.24",
+ "version": "1.0.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devnogi-front",
- "version": "1.0.24",
+ "version": "1.0.25",
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-checkbox": "^1.3.3",
diff --git a/package.json b/package.json
index 43f9f40..f779b57 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devnogi-front",
- "version": "1.0.24",
+ "version": "1.0.25",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/scripts/test-production-workflow.mjs b/scripts/test-production-workflow.mjs
index ab57e81..8acf877 100644
--- a/scripts/test-production-workflow.mjs
+++ b/scripts/test-production-workflow.mjs
@@ -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);
});
diff --git a/scripts/workflow-contract.test.mjs b/scripts/workflow-contract.test.mjs
index 07be63c..573e2cd 100644
--- a/scripts/workflow-contract.test.mjs
+++ b/scripts/workflow-contract.test.mjs
@@ -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(
diff --git a/src/components/page/community/CommentItem.tsx b/src/components/page/community/CommentItem.tsx
index 089e463..86b3518 100644
--- a/src/components/page/community/CommentItem.tsx
+++ b/src/components/page/community/CommentItem.tsx
@@ -1,7 +1,5 @@
"use client";
-import { formatDistanceToNow } from "date-fns";
-import { ko } from "date-fns/locale";
import {
Check,
Flag,
@@ -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 {
@@ -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 (
@@ -278,7 +266,7 @@ export default function CommentItem({
{comment.createdAt && (
- {formatDate(comment.createdAt)}
+
)}
diff --git a/src/components/page/community/CommunityTime.test.tsx b/src/components/page/community/CommunityTime.test.tsx
new file mode 100644
index 0000000..d5e3109
--- /dev/null
+++ b/src/components/page/community/CommunityTime.test.tsx
@@ -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()).toBe(
+ '',
+ );
+ render();
+ 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();
+ expect(container.querySelector("time")).toHaveAttribute(
+ "datetime",
+ value,
+ );
+ },
+ );
+
+ it("does not throw on an unavailable timestamp", () => {
+ render();
+ });
+});
diff --git a/src/components/page/community/CommunityTime.tsx b/src/components/page/community/CommunityTime.tsx
new file mode 100644
index 0000000..defd1f3
--- /dev/null
+++ b/src/components/page/community/CommunityTime.tsx
@@ -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 ;
+}
diff --git a/src/components/page/community/PopularPostsHighlight.tsx b/src/components/page/community/PopularPostsHighlight.tsx
index f38a38d..659c1da 100644
--- a/src/components/page/community/PopularPostsHighlight.tsx
+++ b/src/components/page/community/PopularPostsHighlight.tsx
@@ -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;
}
@@ -97,10 +97,7 @@ export function PopularPostsContent({
{post.commentCount.toLocaleString()}
- {formatDistanceToNow(new Date(post.createdAt), {
- addSuffix: true,
- locale: ko,
- })}
+
diff --git a/src/components/page/community/PostCard.test.tsx b/src/components/page/community/PostCard.test.tsx
index bda8dcf..49da2c8 100644
--- a/src/components/page/community/PostCard.test.tsx
+++ b/src/components/page/community/PostCard.test.tsx
@@ -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,
@@ -50,6 +58,120 @@ const post: PostSummary = {
};
describe("PostCard", () => {
+ const card = (item: PostSummary) => (
+
+
+
+ );
+
+ 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;
+ 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(
diff --git a/src/components/page/community/PostCard.tsx b/src/components/page/community/PostCard.tsx
index b57afcc..54f62d1 100644
--- a/src/components/page/community/PostCard.tsx
+++ b/src/components/page/community/PostCard.tsx
@@ -1,7 +1,5 @@
"use client";
-import { formatDistanceToNow } from "date-fns";
-import { ko } from "date-fns/locale";
import {
Bookmark,
Eye,
@@ -23,6 +21,7 @@ import { useAuth } from "@/contexts/AuthContext";
import type { PostSummary } from "@/hooks/useInfinitePosts";
import { clientAxios } from "@/lib/api/clients";
+import CommunityTime from "./CommunityTime";
import PostComposer from "./PostComposer";
import ReportDialog from "./ReportDialog";
@@ -32,9 +31,37 @@ interface Props {
export default function PostCard({ post }: Props) {
const { user, isAuthenticated } = useAuth();
- const [liked, setLiked] = useState(post.liked);
- const [likeCount, setLikeCount] = useState(post.likeCount);
- const [bookmarked, setBookmarked] = useState(post.bookmarked);
+ const [like, setLike] = useState({
+ sourceLiked: post.liked,
+ sourceCount: post.likeCount,
+ liked: post.liked,
+ count: post.likeCount,
+ pending: false,
+ });
+ const [bookmark, setBookmark] = useState({
+ source: post.bookmarked,
+ value: post.bookmarked,
+ pending: false,
+ });
+ // Reconcile fresh server props before rendering, but let an in-flight write finish.
+ if (like.sourceLiked !== post.liked || like.sourceCount !== post.likeCount) {
+ setLike({
+ sourceLiked: post.liked,
+ sourceCount: post.likeCount,
+ liked: like.pending ? like.liked : post.liked,
+ count: like.pending ? like.count : post.likeCount,
+ pending: like.pending,
+ });
+ }
+ if (bookmark.source !== post.bookmarked) {
+ setBookmark({
+ source: post.bookmarked,
+ value: bookmark.pending ? bookmark.value : post.bookmarked,
+ pending: bookmark.pending,
+ });
+ }
+ const { liked, count: likeCount } = like;
+ const bookmarked = bookmark.value;
const [menuOpen, setMenuOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [editing, setEditing] = useState(false);
@@ -43,29 +70,60 @@ export default function PostCard({ post }: Props) {
const profileHref = `/profile/${post.userId}`;
const toggleLike = async () => {
+ if (like.pending) return;
const previous = liked;
- setLiked(!previous);
- setLikeCount((count) => count + (previous ? -1 : 1));
+ setLike({
+ ...like,
+ liked: !previous,
+ count: likeCount + (previous ? -1 : 1),
+ pending: true,
+ });
try {
await clientAxios.post("/posts/like", { postId: post.id });
+ setLike((current) => ({
+ ...current,
+ count:
+ current.sourceCount +
+ (current.sourceLiked === current.liked ? 0 : current.liked ? 1 : -1),
+ pending: false,
+ }));
} catch {
- setLiked(previous);
- setLikeCount((count) => count + (previous ? 1 : -1));
+ setLike((current) => ({
+ ...current,
+ liked:
+ current.sourceLiked === like.sourceLiked &&
+ current.sourceCount === like.sourceCount
+ ? like.liked
+ : current.sourceLiked,
+ count:
+ current.sourceLiked === like.sourceLiked &&
+ current.sourceCount === like.sourceCount
+ ? like.count
+ : current.sourceCount,
+ pending: false,
+ }));
toast.error("좋아요를 반영하지 못했어요.");
}
};
const toggleBookmark = async () => {
+ if (bookmark.pending) return;
const previous = bookmarked;
- setBookmarked(!previous);
+ setBookmark({ ...bookmark, value: !previous, pending: true });
try {
await clientAxios.request({
url: `/posts/${post.id}/bookmark`,
method: previous ? "DELETE" : "PUT",
});
} catch {
- setBookmarked(previous);
+ setBookmark((current) => ({
+ ...current,
+ value:
+ current.source === bookmark.source ? bookmark.value : current.source,
+ }));
toast.error("즐겨찾기를 반영하지 못했어요.");
+ } finally {
+ setBookmark((current) => ({ ...current, pending: false }));
}
};
@@ -155,10 +213,7 @@ export default function PostCard({ post }: Props) {
href={`/community/${post.id}`}
className="hover:underline"
>
- {formatDistanceToNow(new Date(post.createdAt), {
- addSuffix: true,
- locale: ko,
- })}
+
@@ -261,6 +316,7 @@ export default function PostCard({ post }: Props) {