Skip to content

Server-render entry word count, read time and relative dates - #1663

Merged
feruzm merged 2 commits into
developfrom
perf/ssr-entry-stats-1662
Aug 24, 2026
Merged

Server-render entry word count, read time and relative dates#1663
feruzm merged 2 commits into
developfrom
perf/ssr-entry-stats-1662

Conversation

@feruzm

@feruzm feruzm commented Aug 24, 2026

Copy link
Copy Markdown
Member

Closes #1662

The word count, read time and relative dates were the last visual changes on a post page: server HTML carried "0", "0 min" and a UTC datetime, which flipped to real values only after hydration (~4.4s in lab runs, the final visually-complete step in WPT 260824_97_3).

  • entry-page-listen.tsx: wordCount/readTime become useMemo derivations of entry.body (pure, available server-side), replacing useState(0) + useMount. countWords is exported for the spec.
  • TimeLabel: relative modes now initialize display with the computed relative form, so SSR emits "3d" instead of the UTC string. Relative forms are differences of two instants and timezone-independent; mode="absolute" keeps the UTC-first-paint contract since a local format depends on the viewer's timezone. The existing suppressHydrationWarning absorbs the boundary case where an edge-cached page's SSR value is one unit stale until the mount effect corrects it.
  • Specs pin the SSR output with renderToString (no effects, like the server): the stats render real values and relative TimeLabels render the relative form. Verified the pins fail against the previous implementation.
  • Test setup: added useAiAssist and i18next.language to the global mocks (the component under test needs both at render time).

Summary by CodeRabbit

  • Bug Fixes
    • Post word counts and estimated reading times now appear correctly during initial page rendering, avoiding temporary zero values.
    • Time labels display accurate relative and absolute timestamps during server rendering and after hydration.
  • Tests
    • Added coverage for server-rendered word counts, reading times, and time-label hydration behavior.
    • Expanded test setup coverage for localization and AI assistance states.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Your Qodo trial ends soon. Ask your workspace admin to set up billing to keep reviews running after the trial. Manage billing

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 24, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Internal modules mocked with vi.mock 📘 Rule violation ▣ Testability
Description
The new SSR spec mocks multiple internal app modules (e.g., @/features/shared,
@/api/translation) instead of only mocking external package dependencies. This increases test
brittleness and violates the unit-test mocking restriction.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R19-23]

+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}</>
+}));
Relevance

●●● Strong

Team consistently accepts removing internal vi.mock calls per unit-test mocking policy in recent
PRs.

PR-#1657
PR-#1503
PR-#1541

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668008 disallows mocking internal application modules with vi.mock/vi.fn. The
new spec file introduces vi.mock() calls for internal aliased modules under @/ and @ui/, which
are not external packages.

Rule 2668008: Mock only external package dependencies with vi.fn in unit tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[19-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx` uses `vi.mock()` to replace internal application modules (e.g., `@/features/shared`, `@/features/text-to-speech`, `@/api/translation`, `@/config`, `@ui/modal`). The compliance rule requires that unit tests mock only external package dependencies with Vitest mocks.
## Issue Context
This is an SSR regression-pin test; prefer exercising real internal modules and, if needed, mock only true external boundaries (or refactor the component to inject seams without module mocking).
## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[14-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Entry mocked without factory ✓ Resolved 📘 Rule violation ▣ Testability
Description
The new SSR spec constructs an Entry via { body } as Entry instead of using the shared
mockEntry factory. This bypasses standardized defaults and makes tests easier to break when the
Entry shape evolves.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R42-43]

+  const body = Array.from({ length: 574 }, (_, i) => `word${i}`).join(" ");
+  const entry = { body } as Entry;
Relevance

●●● Strong

Team consistently requires shared mock factories instead of inline domain-object casts in tests.

PR-#1535
PR-#1545
PR-#1565

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668103 requires using shared factory helpers for domain-shaped mock data when
available. The new spec creates an Entry via a minimal inline literal and type assertion instead
of calling the shared mockEntry factory.

Rule 2668103: Use shared factory helpers for mock data in tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test builds a domain-shaped `Entry` inline (`{ body } as Entry`) instead of using the shared factories from `apps/web/src/specs/test-utils.tsx`.
## Issue Context
A `mockEntry()` factory exists and provides stable defaults while allowing overrides (e.g., `mockEntry({ body })`). Using it avoids brittle casts and keeps mock data consistent across the suite.
## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Entry mocked without factory ✓ Resolved 📘 Rule violation ▣ Testability
Description
The new SSR spec constructs an Entry via { body } as Entry instead of using the shared
mockEntry factory. This bypasses standardized defaults and makes tests easier to break when the
Entry shape evolves.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R42-43]

+  const body = Array.from({ length: 574 }, (_, i) => `word${i}`).join(" ");
+  const entry = { body } as Entry;
Relevance

●●● Strong

Team consistently requires shared mock factories instead of inline domain-object casts in tests.

PR-#1535
PR-#1545
PR-#1565

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668103 requires using shared factory helpers for domain-shaped mock data when
available. The new spec creates an Entry via a minimal inline literal and type assertion instead
of calling the shared mockEntry factory.

Rule 2668103: Use shared factory helpers for mock data in tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test builds a domain-shaped `Entry` inline (`{ body } as Entry`) instead of using the shared factories from `apps/web/src/specs/test-utils.tsx`.
## Issue Context
A `mockEntry()` factory exists and provides stable defaults while allowing overrides (e.g., `mockEntry({ body })`). Using it avoids brittle casts and keeps mock data consistent across the suite.
## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
4. Internal modules mocked with vi.mock ✗ Dismissed 📘 Rule violation ▣ Testability
Description
The new SSR spec mocks multiple internal app modules (e.g., @/features/shared,
@/api/translation) instead of only mocking external package dependencies. This increases test
brittleness and violates the unit-test mocking restriction.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R19-23]

+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}</>
+}));
Relevance

●●● Strong

Team consistently accepts removing internal vi.mock calls per unit-test mocking policy in recent
PRs.

PR-#1657
PR-#1503
PR-#1541

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668008 disallows mocking internal application modules with vi.mock/vi.fn. The
new spec file introduces vi.mock() calls for internal aliased modules under @/ and @ui/, which
are not external packages.

Rule 2668008: Mock only external package dependencies with vi.fn in unit tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[19-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx` uses `vi.mock()` to replace internal application modules (e.g., `@/features/shared`, `@/features/text-to-speech`, `@/api/translation`, `@/config`, `@ui/modal`). The compliance rule requires that unit tests mock only external package dependencies with Vitest mocks.
## Issue Context
This is an SSR regression-pin test; prefer exercising real internal modules and, if needed, mock only true external boundaries (or refactor the component to inject seams without module mocking).
## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[14-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Duplicate date parsing ✓ Resolved 🐞 Bug ➹ Performance
Description
TimeLabel now computes the relative display during render while still computing the UTC fallback
(ssrSafe), causing multiple dayjs parses per label on SSR and on each render. This can increase
server render CPU/latency on pages with many TimeLabels (feeds, lists).
Code

apps/web/src/features/shared/time-label/index.tsx[R86-90]

+  const [display, setDisplay] = useState<string | null>(() => {
+    if (mode === "fullRelative") return dateToFullRelative(created);
+    if (mode === "relative") return dateToRelative(created);
+    return null;
+  });
Relevance

●● Moderate

Plausible perf concern but no close accepted or rejected precedent for this exact SSR date-parsing
pattern.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
TimeLabel now initializes display by calling dateToRelative/dateToFullRelative during
render, while still computing ssrSafe via dateToFormattedUtc(created). Both helper functions
normalize and parse the date string using dayjs, so each label does redundant parsing work.

apps/web/src/features/shared/time-label/index.tsx[79-113]
apps/web/src/utils/parse-date.ts[16-36]
apps/web/src/utils/parse-date.ts[64-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`TimeLabel` now computes `display` in the `useState` initializer for relative modes, but also computes `ssrSafe` via `dateToFormattedUtc(created)` every render. Both helpers parse/normalize the date string and create dayjs instances, so the component does duplicate work per label during SSR and client renders.
### Issue Context
- `display` initializer calls `dateToRelative`/`dateToFullRelative` which parse via dayjs.
- `ssrSafe` calls `dateToFormattedUtc`, which also parses via dayjs.
- This is amplified on pages rendering many `TimeLabel`s.
### Fix Focus Areas
- apps/web/src/features/shared/time-label/index.tsx[86-112]
- apps/web/src/utils/parse-date.ts[16-71]
### Suggested approach
- Introduce a single normalization/parsing step inside `TimeLabel` (or a shared helper) and derive both:
- the UTC fallback string
- the initial relative/fullRelative string
from the same parsed dayjs instance (or same normalized string) to avoid multiple parses.
- Keep behavior unchanged (still render relative on SSR for relative modes, and UTC on SSR for absolute mode).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Duplicate date parsing ✓ Resolved 🐞 Bug ➹ Performance
Description
TimeLabel now computes the relative display during render while still computing the UTC fallback
(ssrSafe), causing multiple dayjs parses per label on SSR and on each render. This can increase
server render CPU/latency on pages with many TimeLabels (feeds, lists).
Code

apps/web/src/features/shared/time-label/index.tsx[R86-90]

+  const [display, setDisplay] = useState<string | null>(() => {
+    if (mode === "fullRelative") return dateToFullRelative(created);
+    if (mode === "relative") return dateToRelative(created);
+    return null;
+  });
Relevance

●● Moderate

Plausible perf concern but no close accepted or rejected precedent for this exact SSR date-parsing
pattern.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
TimeLabel now initializes display by calling dateToRelative/dateToFullRelative during
render, while still computing ssrSafe via dateToFormattedUtc(created). Both helper functions
normalize and parse the date string using dayjs, so each label does redundant parsing work.

apps/web/src/features/shared/time-label/index.tsx[79-113]
apps/web/src/utils/parse-date.ts[16-36]
apps/web/src/utils/parse-date.ts[64-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`TimeLabel` now computes `display` in the `useState` initializer for relative modes, but also computes `ssrSafe` via `dateToFormattedUtc(created)` every render. Both helpers parse/normalize the date string and create dayjs instances, so the component does duplicate work per label during SSR and client renders.
### Issue Context
- `display` initializer calls `dateToRelative`/`dateToFullRelative` which parse via dayjs.
- `ssrSafe` calls `dateToFormattedUtc`, which also parses via dayjs.
- This is amplified on pages rendering many `TimeLabel`s.
### Fix Focus Areas
- apps/web/src/features/shared/time-label/index.tsx[86-112]
- apps/web/src/utils/parse-date.ts[16-71]
### Suggested approach
- Introduce a single normalization/parsing step inside `TimeLabel` (or a shared helper) and derive both:
- the UTC fallback string
- the initial relative/fullRelative string
from the same parsed dayjs instance (or same normalized string) to avoid multiple parses.
- Keep behavior unchanged (still render relative on SSR for relative modes, and UTC on SSR for absolute mode).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

7. Client module exports util ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
countWords is now exported from a "use client" component module, coupling a pure utility to a
client-component boundary and making it harder to reuse without importing the entry UI module.
Moving the pure word-count helpers to a shared utils module would keep boundaries cleaner and reduce
future import hazards.
Code

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[R23-26]

+const WORDS_PER_MINUTE = 225;
+
+export function countWords(entry: string): number {
const words = getPurePostTextForWordCount(entry)
Relevance

●●● Strong

Recent precedents accept extracting shared constants/helpers out of route-bound modules for reuse.

PR-#731
PR-#1540

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The module is explicitly a client component and now exports countWords, making a pure utility part
of the client-component module API surface.

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`countWords` (and `WORDS_PER_MINUTE`) are pure helpers but are now exported from `entry-page-listen.tsx`, which is a client component module (`"use client"`). This ties the utility API surface to a UI module and makes later reuse/refactors riskier.
### Issue Context
The function is now imported by SSR specs and could be imported by other code later; keeping it in a dedicated utilities module avoids leaking UI/client-component boundaries.
### Fix Focus Areas
- apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[38-47]
### Suggested approach
- Create `apps/web/src/utils/word-count.ts` exporting `WORDS_PER_MINUTE` and `countWords`.
- Update `EntryPageListen` to import from the new utils module.
- Update the SSR spec to import `countWords` from the utils module instead of the component file.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Client module exports util ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
countWords is now exported from a "use client" component module, coupling a pure utility to a
client-component boundary and making it harder to reuse without importing the entry UI module.
Moving the pure word-count helpers to a shared utils module would keep boundaries cleaner and reduce
future import hazards.
Code

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[R23-26]

+const WORDS_PER_MINUTE = 225;
+
+export function countWords(entry: string): number {
 const words = getPurePostTextForWordCount(entry)
Relevance

●●● Strong

Recent precedents accept extracting shared constants/helpers out of route-bound modules for reuse.

PR-#731
PR-#1540

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The module is explicitly a client component and now exports countWords, making a pure utility part
of the client-component module API surface.

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`countWords` (and `WORDS_PER_MINUTE`) are pure helpers but are now exported from `entry-page-listen.tsx`, which is a client component module (`"use client"`). This ties the utility API surface to a UI module and makes later reuse/refactors riskier.
### Issue Context
The function is now imported by SSR specs and could be imported by other code later; keeping it in a dedicated utilities module avoids leaking UI/client-component boundaries.
### Fix Focus Areas
- apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[38-47]
### Suggested approach
- Create `apps/web/src/utils/word-count.ts` exporting `WORDS_PER_MINUTE` and `countWords`.
- Update `EntryPageListen` to import from the new utils module.
- Update the SSR spec to import `countWords` from the utils module instead of the component file.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b0dd663b-5ccc-48f3-847c-2cd8d4d57f13

📥 Commits

Reviewing files that changed from the base of the PR and between 67bf658 and ea33c80.

📒 Files selected for processing (6)
  • apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx
  • apps/web/src/features/shared/time-label/index.tsx
  • apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx
  • apps/web/src/specs/features/shared/time-label.spec.tsx
  • apps/web/src/specs/setup-any-spec.ts
  • apps/web/src/utils/get-pure-post-text.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The change removes SSR placeholders for entry statistics and relative time labels. Word counts and read times are derived during render. TimeLabel uses server-safe output and corrects client values after hydration. Tests cover SSR and hydration behavior.

Changes

SSR entry metadata

Layer / File(s) Summary
Entry statistics computation
apps/web/src/utils/get-pure-post-text.ts, apps/web/src/app/(dynamicPages)/entry/.../entry-page-listen.tsx, apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx
countPostWords normalizes post text and counts tokens. EntryPageListen derives wordCount and readTime with useMemo. SSR tests verify a 574-word entry renders 574 and 3 min.

Time label SSR and hydration

Layer / File(s) Summary
TimeLabel rendering contract
apps/web/src/features/shared/time-label/index.tsx
Relative modes render server-safe relative values. Absolute mode retains the UTC fallback until client formatting. The UTC fallback is memoized by created.
SSR and hydration validation
apps/web/src/specs/features/shared/time-label.spec.tsx, apps/web/src/specs/setup-any-spec.ts
Tests cover server-rendered relative and absolute values, stale relative text correction after hydration, and supporting mocks for language and AI-assist state.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to ea33c

The PR moves entry statistics and relative dates into server-rendered output while preserving absolute-date behavior; no actionable merge-blocking risk remains beyond normal checks and review.

Poem

A rabbit counts words in a burrow so bright,
SSR shows the answer on first sight.
Time labels bloom with a server-safe glow,
Then hydration makes current values flow.
Hop, hop—no placeholders remain!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes to server-rendered entry statistics and relative dates.
Linked Issues check ✅ Passed The changes satisfy issue #1662 by rendering entry statistics and relative dates during SSR while preserving absolute UTC rendering and hydration coverage.
Out of Scope Changes check ✅ Passed The changes remain within scope and include only implementation, regression tests, and required test-mock updates for issue #1662.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/ssr-entry-stats-1662

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 24, 2026

Copy link
Copy Markdown

PR Summary by Qodo

SSR-render entry stats and relative TimeLabel text

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Compute word count/read time from entry body during SSR to avoid post-hydration flips.
• Initialize TimeLabel relative text on first render while keeping UTC-first-paint for absolute.
• Add SSR-focused specs and extend global test mocks for required render-time dependencies.
Diagram

graph TD
A["Next.js SSR"] --> B["EntryPageListen"] --> C(("countWords")) --> D["SSR HTML"]
A --> E["TimeLabel"] --> F(("dateToRelative")) --> D
H["Client hydration"] --> I["useEffect recompute"] --> D
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Precompute stats in backend/API
  • ➕ Avoids client/server duplication of word-count logic
  • ➕ Eliminates render-time parsing cost on both SSR and CSR
  • ➕ Centralizes canonical stats used across clients
  • ➖ Requires API/schema changes and backfill for existing content
  • ➖ Needs careful parity with current getPurePostTextForWordCount rules
  • ➖ Harder to iterate quickly compared to pure client/server derivation
2. Keep UTC-first-paint for relative TimeLabel
3. Pass viewer timezone/locale hints to SSR
  • ➕ Could SSR-render absolute dates in user locale/timezone
  • ➕ Reduces post-mount swaps for absolute mode
  • ➖ Timezone/locale often unknown to edge caches and can fragment caching
  • ➖ Introduces privacy/caching complexity and inconsistent behavior across deployments

Recommendation: The chosen approach (pure derivations for entry stats + SSR-safe relative initialization) is the best tradeoff for #1662: it removes obvious placeholders without expanding API surface area. Keeping absolute mode UTC-first-paint is appropriate given timezone/locale uncertainty, and the effect-based recompute plus suppressHydrationWarning handles edge-cache staleness cleanly.

Files changed (5) +100 / -16

Bug fix (2) +22 / -16
entry-page-listen.tsxDerive word count/read time during render and export countWords +8/-11

Derive word count/read time during render and export countWords

• Replaces client-only initialization (useMount + useState(0)) with useMemo derivations from entry.body so SSR HTML contains final values. Introduces a WORDS_PER_MINUTE constant and exports countWords for direct testing.

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx

index.tsxInitialize relative TimeLabel display for SSR-safe first paint +14/-5

Initialize relative TimeLabel display for SSR-safe first paint

• Seeds display state with the computed relative string for relative/fullRelative modes so SSR renders the final relative label immediately. Keeps absolute mode using the UTC-safe string until mount, and updates docs to explain hydration/caching behavior with suppressHydrationWarning.

apps/web/src/features/shared/time-label/index.tsx

Tests (3) +78 / -0
entry-page-listen-ssr.spec.tsxAdd SSR pin spec for EntryPageListen stats +58/-0

Add SSR pin spec for EntryPageListen stats

• Adds a renderToString-based test that verifies SSR output includes computed word count and read time and does not contain 0 placeholders. Mocks external collaborators at module seams while exercising countWords and text extraction logic for real.

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx

time-label.spec.tsxAdd SSR expectations for TimeLabel relative vs absolute modes +18/-0

Add SSR expectations for TimeLabel relative vs absolute modes

• Extends the existing TimeLabel spec with renderToString tests to assert that default/relative SSR output uses dateToRelative, while absolute mode SSR output uses dateToFormattedUtc. Keeps existing DOM-based tests for client behavior.

apps/web/src/specs/features/shared/time-label.spec.tsx

setup-any-spec.tsExtend global mocks for i18next.language and useAiAssist +2/-0

Extend global mocks for i18next.language and useAiAssist

• Updates shared vitest setup mocks so components that read i18next.language or call useAiAssist can render in specs without additional per-test wiring.

apps/web/src/specs/setup-any-spec.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5dfd2ec11e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +86 to +89
const [display, setDisplay] = useState<string | null>(() => {
if (mode === "fullRelative") return dateToFullRelative(created);
if (mode === "relative") return dateToRelative(created);
return null;

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.

@qodo-code-review

qodo-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Entry mocked without factory ✓ Resolved 📘 Rule violation ▣ Testability
Description
The new SSR spec constructs an Entry via { body } as Entry instead of using the shared
mockEntry factory. This bypasses standardized defaults and makes tests easier to break when the
Entry shape evolves.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R42-43]

+  const body = Array.from({ length: 574 }, (_, i) => `word${i}`).join(" ");
+  const entry = { body } as Entry;
Relevance

●●● Strong

Team consistently requires shared mock factories instead of inline domain-object casts in tests.

PR-#1535
PR-#1545
PR-#1565

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668103 requires using shared factory helpers for domain-shaped mock data when
available. The new spec creates an Entry via a minimal inline literal and type assertion instead
of calling the shared mockEntry factory.

Rule 2668103: Use shared factory helpers for mock data in tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test builds a domain-shaped `Entry` inline (`{ body } as Entry`) instead of using the shared factories from `apps/web/src/specs/test-utils.tsx`.

## Issue Context
A `mockEntry()` factory exists and provides stable defaults while allowing overrides (e.g., `mockEntry({ body })`). Using it avoids brittle casts and keeps mock data consistent across the suite.

## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[41-44]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Internal modules mocked with vi.mock ✗ Dismissed 📘 Rule violation ▣ Testability
Description
The new SSR spec mocks multiple internal app modules (e.g., @/features/shared,
@/api/translation) instead of only mocking external package dependencies. This increases test
brittleness and violates the unit-test mocking restriction.
Code

apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[R19-23]

+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}</>
+}));
Relevance

●●● Strong

Team consistently accepts removing internal vi.mock calls per unit-test mocking policy in recent
PRs.

PR-#1657
PR-#1503
PR-#1541

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2668008 disallows mocking internal application modules with vi.mock/vi.fn. The
new spec file introduces vi.mock() calls for internal aliased modules under @/ and @ui/, which
are not external packages.

Rule 2668008: Mock only external package dependencies with vi.fn in unit tests
apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[19-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx` uses `vi.mock()` to replace internal application modules (e.g., `@/features/shared`, `@/features/text-to-speech`, `@/api/translation`, `@/config`, `@ui/modal`). The compliance rule requires that unit tests mock only external package dependencies with Vitest mocks.

## Issue Context
This is an SSR regression-pin test; prefer exercising real internal modules and, if needed, mock only true external boundaries (or refactor the component to inject seams without module mocking).

## Fix Focus Areas
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[14-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Duplicate date parsing ✓ Resolved 🐞 Bug ➹ Performance
Description
TimeLabel now computes the relative display during render while still computing the UTC fallback
(ssrSafe), causing multiple dayjs parses per label on SSR and on each render. This can increase
server render CPU/latency on pages with many TimeLabels (feeds, lists).
Code

apps/web/src/features/shared/time-label/index.tsx[R86-90]

+  const [display, setDisplay] = useState<string | null>(() => {
+    if (mode === "fullRelative") return dateToFullRelative(created);
+    if (mode === "relative") return dateToRelative(created);
+    return null;
+  });
Relevance

●● Moderate

Plausible perf concern but no close accepted or rejected precedent for this exact SSR date-parsing
pattern.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
TimeLabel now initializes display by calling dateToRelative/dateToFullRelative during
render, while still computing ssrSafe via dateToFormattedUtc(created). Both helper functions
normalize and parse the date string using dayjs, so each label does redundant parsing work.

apps/web/src/features/shared/time-label/index.tsx[79-113]
apps/web/src/utils/parse-date.ts[16-36]
apps/web/src/utils/parse-date.ts[64-71]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`TimeLabel` now computes `display` in the `useState` initializer for relative modes, but also computes `ssrSafe` via `dateToFormattedUtc(created)` every render. Both helpers parse/normalize the date string and create dayjs instances, so the component does duplicate work per label during SSR and client renders.

### Issue Context
- `display` initializer calls `dateToRelative`/`dateToFullRelative` which parse via dayjs.
- `ssrSafe` calls `dateToFormattedUtc`, which also parses via dayjs.
- This is amplified on pages rendering many `TimeLabel`s.

### Fix Focus Areas
- apps/web/src/features/shared/time-label/index.tsx[86-112]
- apps/web/src/utils/parse-date.ts[16-71]

### Suggested approach
- Introduce a single normalization/parsing step inside `TimeLabel` (or a shared helper) and derive both:
 - the UTC fallback string
 - the initial relative/fullRelative string
 from the same parsed dayjs instance (or same normalized string) to avoid multiple parses.
- Keep behavior unchanged (still render relative on SSR for relative modes, and UTC on SSR for absolute mode).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Client module exports util ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
countWords is now exported from a "use client" component module, coupling a pure utility to a
client-component boundary and making it harder to reuse without importing the entry UI module.
Moving the pure word-count helpers to a shared utils module would keep boundaries cleaner and reduce
future import hazards.
Code

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[R23-26]

+const WORDS_PER_MINUTE = 225;
+
+export function countWords(entry: string): number {
  const words = getPurePostTextForWordCount(entry)
Relevance

●●● Strong

Recent precedents accept extracting shared constants/helpers out of route-bound modules for reuse.

PR-#731
PR-#1540

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The module is explicitly a client component and now exports countWords, making a pure utility part
of the client-component module API surface.

apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`countWords` (and `WORDS_PER_MINUTE`) are pure helpers but are now exported from `entry-page-listen.tsx`, which is a client component module (`"use client"`). This ties the utility API surface to a UI module and makes later reuse/refactors riskier.

### Issue Context
The function is now imported by SSR specs and could be imported by other code later; keeping it in a dedicated utilities module avoids leaking UI/client-component boundaries.

### Fix Focus Areas
- apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-listen.tsx[1-31]
- apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx[38-47]

### Suggested approach
- Create `apps/web/src/utils/word-count.ts` exporting `WORDS_PER_MINUTE` and `countWords`.
- Update `EntryPageListen` to import from the new utils module.
- Update the SSR spec to import `countWords` from the utils module instead of the component file.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 84 rules
✅ Skills: 6 invoked
  add-feature
  add-query
  add-sdk-mutation
  add-test
  code-review
  debug
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx
Comment thread apps/web/src/specs/features/entry/entry-page-listen-ssr.spec.tsx Outdated
Comment thread apps/web/src/features/shared/time-label/index.tsx
@feruzm
feruzm merged commit 3af7d8a into develop Aug 24, 2026
8 checks passed
@feruzm
feruzm deleted the perf/ssr-entry-stats-1662 branch August 24, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Entry stats and dates render placeholder values at SSR and flip after hydration

1 participant