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
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string | null>(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;
Expand Down
36 changes: 28 additions & 8 deletions apps/web/src/features/shared/time-label/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState, useSyncExternalStore } from "react";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import {
dateToFormatted,
dateToFormattedUtc,
Expand Down Expand Up @@ -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". */
Expand All @@ -78,15 +83,30 @@ export function TimeLabel({
format = "LLLL",
className = "date",
}: Props) {
const [display, setDisplay] = useState<string | null>(null);
const [display, setDisplay] = useState<string | null>(() => {
// 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;
Comment on lines +86 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Force a post-hydration relative-date update

When cached server HTML is stale, or the timestamp crosses a formatting boundary before hydration, this initializer gives the client the new relative value while the DOM still contains the server value. Because the span uses suppressHydrationWarning, React does not patch that mismatched text during hydration, and the mount effect then calls setDisplay with the value already held in state, so it can bail out without correcting the DOM. The label can consequently retain the stale server text until the relative string changes again, potentially for days or months; ensure the post-mount correction causes an actual state transition.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in ea33c80, with the mechanism exactly as described: the client initializer already held the corrected value, so the mount effect's setDisplay bailed out on state equality while suppressHydrationWarning had left the server text in the DOM. The initializer is now server-only (typeof window guard), so the client starts at null and the mount effect is always a real state transition whose vdom diff writes the text node. Added the hydration spec you asked for: server HTML rendered at T, hydrated at T+25h with fake timers, asserting the DOM ends on the client value. Verified the spec fails against the previous initializer and passes with the guard.

});
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
const [localFormatted, setLocalFormatted] = useState<string | null>(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));
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx
Original file line number Diff line number Diff line change
@@ -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}</>
}));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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(<EntryPageListen entry={entry} />).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<");
});
});
73 changes: 73 additions & 0 deletions apps/web/src/specs/features/shared/time-label.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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<typeof renderToString>[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(<TimeLabel created={tenDaysAgo} />);
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(<TimeLabel created={tenDaysAgo} mode="absolute" />);
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(<TimeLabel created={created} />);
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, <TimeLabel created={created} />);
});
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";
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/specs/setup-any-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}`],
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/utils/get-pure-post-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading