diff --git a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx index c2b5797074..eb09cbd323 100644 --- a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx +++ b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx @@ -9,25 +9,18 @@ import { Button } from "@/features/ui"; import { Modal, ModalBody, ModalHeader, ModalTitle } from "@ui/modal"; import { Spinner } from "@ui/spinner"; import { Select } from "@ui/input/form-controls/select"; -import { getAccessToken, ensureValidToken, getPurePostText, getPurePostTextForWordCount } from "@/utils"; +import { getAccessToken, ensureValidToken, getPurePostText, countPostWords } from "@/utils"; import { getTranslation, getLanguages, type Language } from "@/api/translation"; import { useAiAssist } from "@ecency/sdk"; import { UilPause, UilPlay, UilSetting } from "@tooni/iconscout-unicons-react"; import i18next from "i18next"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useMount } from "react-use"; interface Props { entry: Entry; } -function countWords(entry: string): number { - const words = getPurePostTextForWordCount(entry) - .trim() - .split(/\s+/) - .filter((word) => word); - return words.length; -} +const WORDS_PER_MINUTE = 225; export function EntryPageListen({ entry }: Props) { const { activeUser } = useActiveAccount(); @@ -39,17 +32,13 @@ export function EntryPageListen({ entry }: Props) { const { mutateAsync: runAssist, isPending: isSummarizing } = useAiAssist(username, accessToken); - const [wordCount, setWordCount] = useState(0); - const [readTime, setReadTime] = useState(0); + // Pure derivations of entry.body, computed during SSR so the server HTML + // carries the final values instead of "0" placeholders that flip after + // hydration (#1662). + const wordCount = useMemo(() => countPostWords(entry.body), [entry.body]); + const readTime = useMemo(() => Math.ceil(wordCount / WORDS_PER_MINUTE), [wordCount]); const [summary, setSummary] = useState(null); - useMount(() => { - const entryCount = countWords(entry.body); - const wordPerMinuite: number = 225; - setWordCount(entryCount); - setReadTime(Math.ceil(entryCount / wordPerMinuite)); - }); - const handleClick = useCallback(() => { if (!speechRef.current) { return; diff --git a/apps/web/src/features/shared/time-label/index.tsx b/apps/web/src/features/shared/time-label/index.tsx index ebab413bcd..8ed715ca66 100644 --- a/apps/web/src/features/shared/time-label/index.tsx +++ b/apps/web/src/features/shared/time-label/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { dateToFormatted, dateToFormattedUtc, @@ -60,10 +60,15 @@ interface Props { * - "fullRelative": long form, e.g. "5 hours ago" * - "absolute": formatted date using `format` * - * The first paint (SSR + initial client render) is always a UTC numeric - * string so the two sides agree and React doesn't fire hydration error - * #418. After mount the visible text and tooltip swap to the user's local - * timezone and locale. + * Relative modes render their value from the first SSR paint: a relative + * form is the difference of two instants, so it is timezone-independent + * and safe to compute server-side (#1662). The mount effect re-computes it + * so an edge-cached page self-corrects; `suppressHydrationWarning` on the + * span absorbs the boundary case where the two sides disagree by one unit. + * + * For "absolute" the first paint stays a UTC numeric string (a local + * format depends on the viewer's timezone and locale, which the server + * cannot know) and swaps after mount. */ mode?: Mode; /** dayjs format token used when `mode` is "absolute". Defaults to "LLLL". */ @@ -78,15 +83,30 @@ export function TimeLabel({ format = "LLLL", className = "date", }: Props) { - const [display, setDisplay] = useState(null); + const [display, setDisplay] = useState(() => { + // SERVER-ONLY initializer, deliberately. The client must start at null: + // with suppressHydrationWarning React keeps the server text in the DOM on + // a mismatch while its vdom holds the client-rendered value, so if the + // client initializer computed its own (possibly newer) relative value, + // the mount effect's setDisplay() would bail out on state equality and + // the stale server text would stay visible until the NEXT unit change + // (a day, even a month). Starting at null makes the mount effect a real + // state transition whose vdom diff (UTC fallback -> relative) always + // writes the text node. + if (typeof window !== "undefined") return null; + if (mode === "fullRelative") return dateToFullRelative(created); + if (mode === "relative") return dateToRelative(created); + return null; + }); const [localFormatted, setLocalFormatted] = useState(null); // Self-tick: re-runs the formatting effect ~once a minute for relative modes, // so timestamps stay fresh without the parent re-rendering the whole card. const tick = useTick(mode === "relative" || mode === "fullRelative"); - // Numeric UTC — identical on server and client, no hydration mismatch. - const ssrSafe = dateToFormattedUtc(created); + // Numeric UTC fallback; memoized so feeds full of labels parse each date + // once per value instead of on every render. + const ssrSafe = useMemo(() => dateToFormattedUtc(created), [created]); useEffect(() => { setLocalFormatted(dateToFormatted(created)); diff --git a/apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx b/apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx new file mode 100644 index 0000000000..b944f05e75 --- /dev/null +++ b/apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx @@ -0,0 +1,59 @@ +import { vi, describe, it, expect } from "vitest"; +import { renderToString } from "react-dom/server"; +import type { ReactNode } from "react"; + +/* + SSR pin for #1662: the word count and read time are pure derivations of + entry.body and must be present in the server-rendered HTML, not "0" + placeholders that flip after hydration. renderToString runs no effects, + exactly like the server, so a regression back to useMount renders 0 here. + + The collaborators are mocked at their module seams; the component itself, + countWords and the real getPurePostTextForWordCount run for real. +*/ +vi.mock("@/utils", async () => ({ + ...(await vi.importActual("@/utils")), + getAccessToken: vi.fn(() => undefined), + ensureValidToken: vi.fn() +})); +vi.mock("@/features/shared", () => ({ error: vi.fn(), success: vi.fn() })); +vi.mock("@/features/text-to-speech", () => ({ + useTts: vi.fn(() => ({ speechRef: { current: undefined }, hasPaused: false, hasStarted: false })), + TextToSpeechSettingsDialog: ({ children }: { children: ReactNode }) => <>{children} +})); +vi.mock("@/api/translation", () => ({ + getTranslation: vi.fn(), + getLanguages: vi.fn(async () => []) +})); +vi.mock("@/config", () => ({ + EcencyConfigManager: { useConfig: vi.fn(() => false) } +})); +vi.mock("@ui/modal", () => ({ + Modal: () => null, + ModalBody: () => null, + ModalHeader: () => null, + ModalTitle: () => null +})); + +import { EntryPageListen } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen"; +import { countPostWords } from "@/utils"; +import { mockEntry } from "@/specs/test-utils"; + +describe("EntryPageListen SSR output (#1662)", () => { + const body = Array.from({ length: 574 }, (_, i) => `word${i}`).join(" "); + const entry = mockEntry({ body }); + + it("computes the body-derived stats it renders", () => { + expect(countPostWords(body)).toBe(574); + }); + + it("server-renders the real word count and read time, not 0 placeholders", () => { + // React separates adjacent text expressions with comment nodes; strip them + // so the assertion reads like the visible text. + const html = renderToString().replace(//g, ""); + expect(html).toContain(">574<"); + // 574 words at 225 wpm rounds up to 3 minutes. + expect(html).toMatch(/>3 entry\.post-read-minutes/); + expect(html).not.toContain(">0<"); + }); +}); diff --git a/apps/web/src/specs/features/shared/time-label.spec.tsx b/apps/web/src/specs/features/shared/time-label.spec.tsx index 5fa14709c4..b486420032 100644 --- a/apps/web/src/specs/features/shared/time-label.spec.tsx +++ b/apps/web/src/specs/features/shared/time-label.spec.tsx @@ -1,5 +1,6 @@ import { vi, describe, it, expect } from "vitest"; import { render } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; import "@testing-library/jest-dom"; vi.mock("@/utils", async () => { @@ -8,6 +9,78 @@ vi.mock("@/utils", async () => { }); import { TimeLabel } from "@/features/shared/time-label"; +import { dateToFormattedUtc, dateToRelative } from "@/utils"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { act } from "react"; + +/* + TimeLabel's display initializer is server-only (typeof window guard), so a + jsdom renderToString would take the CLIENT branch. Stub window off to + exercise the real server path. +*/ +function renderServerHtml(ui: Parameters[0]): string { + const win = globalThis.window; + // @ts-expect-error deliberately simulating the server environment + delete globalThis.window; + try { + return renderToString(ui); + } finally { + globalThis.window = win; + } +} + +describe("TimeLabel SSR output (#1662)", () => { + // A fixed offset from now, so the relative form is deterministic per run. + const tenDaysAgo = new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString(); + + it("server-renders the relative form for the default mode, not the UTC datetime", () => { + const html = renderServerHtml(); + expect(html).toContain(`>${dateToRelative(tenDaysAgo)}<`); + expect(html).not.toContain(`>${dateToFormattedUtc(tenDaysAgo)}<`); + }); + + it("server-renders the UTC datetime for absolute mode (viewer timezone unknown)", () => { + const html = renderServerHtml(); + expect(html).toContain(`>${dateToFormattedUtc(tenDaysAgo)}<`); + }); + + it("corrects a stale cached SSR value after hydration", async () => { + /* + Edge-cached pages serve HTML whose SSR-computed relative value can be a + unit behind by hydration time. suppressHydrationWarning makes React keep + the server text on mismatch, so the correction must be a real state + transition; a client-side initializer that pre-computes the new value + makes the mount effect bail out on state equality and the DOM stays + stale (reproduced with React 19). + */ + vi.useFakeTimers(); + try { + const created = new Date("2026-01-01T00:00:00Z").toISOString(); + vi.setSystemTime(new Date("2026-01-04T12:00:00Z")); + const serverValue = dateToRelative(created); + const html = renderServerHtml(); + expect(html).toContain(`>${serverValue}<`); + + // The viewer loads the cached HTML 25 hours later: one relative unit on. + vi.setSystemTime(new Date("2026-01-05T13:00:00Z")); + const clientValue = dateToRelative(created); + expect(clientValue).not.toBe(serverValue); + + const container = document.createElement("div"); + container.innerHTML = html; + document.body.appendChild(container); + let root: Root | undefined; + await act(async () => { + root = hydrateRoot(container, ); + }); + expect(container.querySelector("span")?.textContent).toBe(clientValue); + await act(async () => root?.unmount()); + container.remove(); + } finally { + vi.useRealTimers(); + } + }); +}); describe("TimeLabel", () => { const created = "2024-06-15T12:00:00Z"; diff --git a/apps/web/src/specs/setup-any-spec.ts b/apps/web/src/specs/setup-any-spec.ts index 677a173ebd..168c6f2047 100644 --- a/apps/web/src/specs/setup-any-spec.ts +++ b/apps/web/src/specs/setup-any-spec.ts @@ -45,6 +45,7 @@ vi.mock("i18next", () => ({ __esModule: true, default: { t: vi.fn((key) => key), + language: "en-US", init: vi.fn(), changeLanguage: vi.fn(), on: vi.fn() @@ -157,6 +158,7 @@ vi.mock("@ecency/sdk", async () => ({ queryFn: vi.fn() })), useRcDelegation: vi.fn(() => ({ mutateAsync: vi.fn() })), + useAiAssist: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })), buildRcDelegationOp: vi.fn(() => ({})), getDeletedEntryQueryOptions: vi.fn((author, permlink) => ({ queryKey: ["posts", "deleted-entry", `@${author}/${permlink}`], diff --git a/apps/web/src/utils/get-pure-post-text.ts b/apps/web/src/utils/get-pure-post-text.ts index 1b67637cb8..34d4a30d4c 100644 --- a/apps/web/src/utils/get-pure-post-text.ts +++ b/apps/web/src/utils/get-pure-post-text.ts @@ -43,3 +43,13 @@ export function getPurePostTextForWordCount(text: string) { text = text.replace(/[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]/g, " $& "); return text; } + +/** + * Word count of a post body, as shown in the entry stats bar. + */ +export function countPostWords(body: string): number { + return getPurePostTextForWordCount(body) + .trim() + .split(/\s+/) + .filter((word) => word).length; +}