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) {