Skip to content

Detect a feed card's language on the server instead of in every browser - #1618

Merged
feruzm merged 1 commit into
developfrom
perf/server-side-lang-hint
Aug 21, 2026
Merged

Detect a feed card's language on the server instead of in every browser#1618
feruzm merged 1 commit into
developfrom
perf/server-side-lang-hint

Conversation

@feruzm

@feruzm feruzm commented Aug 21, 2026

Copy link
Copy Markdown
Member

The Translate chip on a card needs the content language. Until now every visitor's browser detected it: one markdown render per card plus the franc-min detector chunk (about 47 KB gzipped), on idle after every feed and post view, for the majority of readers whose language matches the content.

The slim step already derives each card's summary on the server, so the server now detects the language from that same summary once per fetch and ships it as slim.lang (ISO-639-1, or null when the text is too short or the detector is unsure). The sample is rendered to plain text with the same bounds the client applied, so an author description that is only an image link or markup counts as no text rather than as a language. The client gate reads the hint, caches it under the summary key as before, and skips both the render and the chunk. Rows the browser fetches itself (later pages of an infinite feed) carry no hint and keep the on-idle path, the feed poll's merge preserves the hint on rows it refreshes, and the post page keeps detecting on the full body. The detector is imported lazily inside the server branch only, so it never enters a client bundle; a failing import leaves rows without a hint (and is retried on the next fetch) rather than failing the query.

Measured on the production build served locally, against the current develop build on staging: the franc chunk is requested 0 times on /trending and /trending/spanish (1 time each before), the SSR payload carries the hints (35 on /trending), and the chips still render where languages differ (10 on /trending/spanish for an English reader, same as before).

Test plan

  • language-hint.spec.ts (node): Spanish/English summaries get es/en, short text gets null, cross-post originals are covered, rows without the slim marker and non-array pages are untouched, a throwing detector never escapes, link/markup-only descriptions count as no text, and the poll merge keeps the hint. language-hint-client.spec.ts (jsdom): no-op in the browser.
  • entry-translate-language-gate.spec.tsx: a hinted slim row decides without loading franc, a null hint offers nothing, a full body ignores a stale hint.
  • Full suite, tsc --noEmit and next lint clean.

Closes #1597

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

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Unsafe description trim 🐞 Bug ☼ Reliability
Description
hintFor() calls .trim() on json_metadata.description without validating it’s a string, so a
malformed metadata shape can throw and break annotateLanguageHints() (and thus the server
queryFn). This contradicts the “Never throws” behavior promised for language hints and can cause
SSR/prefetch failures for affected rows/pages.
Code

apps/web/src/core/entries/language-hint.ts[R61-64]

+  const raw = entry.body
+    ? "" // a full body is the post page's business; hints are for slim rows
+    : ((entry.json_metadata?.description as string | null | undefined) ?? "").trim();
+  if (raw.length < MIN_DETECT_CHARS) return null;
Relevance

●●● Strong

Recent accepted precedents require runtime guards for untrusted shapes and preserving never-throw
reliability contracts.

PR-#1024
PR-#954

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new code trims description without a runtime type guard, so a non-string value will throw
before hintFor()’s try/catch. The codebase already documents that json_metadata fields are
untrusted and guards description with typeof === "string" elsewhere, indicating this is a real
input possibility.

apps/web/src/core/entries/language-hint.ts[57-70]
apps/web/src/core/entries/slim-entry.ts[85-89]

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/core/entries/language-hint.ts` assumes `entry.json_metadata.description` is always a string and does `(... ?? "").trim()`. In real data, `json_metadata` fields are not trustworthy (other code already defends against this), and a non-string `description` will throw before the internal try/catch, breaking the server-side query.
### Issue Context
The module comment says hint annotation “Never throws”. This currently isn’t guaranteed because `.trim()` executes outside the guarded `try { ... }`.
### Fix Focus Areas
- apps/web/src/core/entries/language-hint.ts[57-71]
### Suggested change
- Read `description` as `unknown` and only trim/use it if `typeof description === "string"`.
- (Optional) Move the raw extraction inside the `try` block or add a small `try/catch` around it to uphold the “never throws” contract.
Example:

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



Remediation recommended

2. Hardcoded queryKey: ["hint"] ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
The new test uses a hardcoded React Query key array (["hint"]) instead of using a QueryKeys
builder/constant. This can lead to inconsistent cache key conventions and makes later
refactors/invalidation harder.
Code

apps/web/src/specs/core/entries/language-hint.spec.ts[R22-26]

+  it("detects the summary language of slim rows and ships it as slim.lang", async () => {
+    const options = withSlimEntries({
+      queryKey: ["hint"],
+      queryFn: async () => [row(SPANISH), row(ENGLISH)]
+    });
Relevance

●● Moderate

Recent history is mixed: centralized query keys are accepted in some tests but rejected as
unnecessary in others.

PR-#1563
PR-#1565

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2667922 requires using QueryKeys rather than hardcoded literals for React Query
keys. The added test constructs queryKey: ["hint"] directly when calling withSlimEntries(...).

Rule 2667922: Use QueryKeys constants for react-query keys instead of hardcoded literals
apps/web/src/specs/core/entries/language-hint.spec.ts[22-26]

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

## Issue description
A new test passes a hardcoded React Query `queryKey` (`["hint"]`) instead of deriving it from `QueryKeys`.
## Issue Context
Per compliance, query keys should come from `QueryKeys` (imported from `@ecency/sdk`) to keep cache key conventions centralized.
## Fix Focus Areas
- apps/web/src/specs/core/entries/language-hint.spec.ts[22-45]

ⓘ 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 tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@feruzm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c336f63-6a85-478b-a566-52a8990a3275

📥 Commits

Reviewing files that changed from the base of the PR and between 9d6f747 and 2badef4.

📒 Files selected for processing (9)
  • apps/web/src/api/queries/get-account-posts-feed-query.ts
  • apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx
  • apps/web/src/core/entries/language-hint.ts
  • apps/web/src/core/entries/slim-entry.ts
  • apps/web/src/entities/entries.ts
  • apps/web/src/features/shared/entry-translate/use-content-language-gate.ts
  • apps/web/src/specs/core/entries/language-hint-client.spec.ts
  • apps/web/src/specs/core/entries/language-hint.spec.ts
  • apps/web/src/specs/features/entry-translate-language-gate.spec.tsx

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-code-review

qodo-code-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Summary by Qodo

Move feed-card language detection to server-side slim rows

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Detect slim feed-row summary language on the server and ship it as slim.lang.
• Skip client markdown rendering and franc-min chunk when a server hint exists.
• Preserve hints during feed polling merges; add coverage for server/client paths.
Diagram

graph TD
  subgraph Server["Server runtime"]
    Q["Feed query (SSR)"] --> S["Slim entries"] --> H["Annotate slim.lang"] --> C[("React Query cache")]
    H -->|"lazy import"| F["franc-min"]
  end

  subgraph Browser["Browser runtime"]
    UI["Feed UI"] --> C --> G["Translate gate"] -->|"hint undefined"| I["Idle detect"] -->|"dynamic import"| F
    P["Feed poll merge"] --> C
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist language at ingestion/index time
  • ➕ Avoids repeated detection per fetch across all server instances
  • ➕ Makes language available consistently for all fetch paths (SSR and client pagination)
  • ➖ Requires schema/storage changes and backfill for existing posts
  • ➖ Harder to keep in sync if summaries/metadata change over time
2. Dedicated server /detect endpoint for summaries
  • ➕ Centralizes detection behind an API with better observability and caching
  • ➕ Can swap detectors without touching feed query plumbing
  • ➖ Adds request overhead and failure modes to feed fetch
  • ➖ Still requires careful bundling to avoid client inclusion

Recommendation: The PR’s approach is the best incremental optimization: it reuses the existing slim-summary derivation, keeps client behavior unchanged when hints are absent, avoids bundling franc-min into client builds via server-only lazy import, and includes merge logic to prevent regressions during polling. Persisting language at ingestion could be a future follow-up if you want hints on client-fetched pages as well.

Files changed (9) +314 / -7

Enhancement (5) +130 / -5
get-account-posts-feed-query.tsAnnotate promoted feed pages with server language hints +2/-1

Annotate promoted feed pages with server language hints

• Wraps the promoted entries slim page transform with 'annotateLanguageHints()' so SSR-fetched pages include 'slim.lang' per row.

apps/web/src/api/queries/get-account-posts-feed-query.ts

language-hint.tsAdd server-only slim-row language hinting utilities +105/-0

Add server-only slim-row language hinting utilities

• Introduces server-side detection from the same summary text the client gate uses, lazily importing 'franc-min' and writing results to 'slim.lang' (ISO-639-1 or null). Includes safeguards for short/markup-only text, recursion into 'original_entry', and a merge helper to preserve hints.

apps/web/src/core/entries/language-hint.ts

slim-entry.tsAnnotate slim query results with language hints during queryFn +6/-1

Annotate slim query results with language hints during queryFn

• Wraps slimmed queryFn results with 'annotateLanguageHints()' so server-prefetched slim rows carry hints while client execution remains a no-op.

apps/web/src/core/entries/slim-entry.ts

entries.tsExtend Entry.slim type to include optional 'lang' hint +5/-2

Extend Entry.slim type to include optional 'lang' hint

• Documents and types 'slim.lang' as an optional ISO-639-1 hint (or null) present only on server-slimmed rows, absent for browser-fetched pages.

apps/web/src/entities/entries.ts

use-content-language-gate.tsConsume 'slim.lang' to bypass client detection when available +12/-1

Consume 'slim.lang' to bypass client detection when available

• Reads the server hint for summary-based (slim) entries and immediately resolves CTA decisions without markdown rendering or importing 'franc-min'. Keeps full-body posts on the existing detection path and keys/caching behavior intact.

apps/web/src/features/shared/entry-translate/use-content-language-gate.ts

Bug fix (1) +3 / -2
feed-layout.tsxPreserve 'slim.lang' across poll refresh merges +3/-2

Preserve 'slim.lang' across poll refresh merges

• Replaces spread-based row merging with 'mergePreservingHint()' for both cached pages and the extra list, preventing browser-polled updates from wiping server-provided hints.

apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx

Tests (3) +181 / -0
language-hint-client.spec.tsAssert language hinting is a no-op in browser (jsdom) +22/-0

Assert language hinting is a no-op in browser (jsdom)

• Adds a jsdom test ensuring 'annotateLanguageHints()' does not add 'slim.lang' on the client, preserving the on-idle detection behavior.

apps/web/src/specs/core/entries/language-hint-client.spec.ts

language-hint.spec.tsAdd node tests for hint detection, failure safety, and merge behavior +106/-0

Add node tests for hint detection, failure safety, and merge behavior

• Covers server runtime detection ('es'/'en'), short/undetermined ('null'), cross-post originals, non-slim/non-array pass-through, detector failure isolation, markup/link-only summaries treated as no text, preserving hints on poll merges, and skipping full-body posts.

apps/web/src/specs/core/entries/language-hint.spec.ts

entry-translate-language-gate.spec.tsxTest translate gate behavior with hinted, null-hinted, and full-body rows +53/-0

Test translate gate behavior with hinted, null-hinted, and full-body rows

• Adds tests verifying hinted slim rows avoid loading 'franc-min', 'null' hints produce no CTA, and full-body entries ignore stale hints and still run detection.

apps/web/src/specs/features/entry-translate-language-gate.spec.tsx

@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: 855d742f3a

ℹ️ 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 +80 to +83
const lang = item.slim?.lang;
const merged = { ...item, ...updated };
if (lang === undefined || !updated.slim || updated.slim.lang !== undefined) return merged;
return { ...merged, slim: { ...updated.slim, lang } };

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 Preserve nested cross-post hints during poll merges

When the trending/hot/created poll refreshes a cross-post, the shallow spread replaces the cached original_entry with the browser-fetched copy, but this helper preserves only the outer row's slim.lang. Because feed cards unwrap original_entry and pass that nested entry to the translation gate, a cross-post whose chip has not mounted before the poll loses its server hint and falls back to rendering the summary and loading franc-min. Preserve hints recursively for original_entry as well as at the top level.

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.

Taken: mergePreservingHint now recurses into original_entry, so a cross-post's nested original keeps its hint when the poll replaces it; covered by the merge spec.

@feruzm
feruzm force-pushed the perf/server-side-lang-hint branch from 855d742 to 34bb849 Compare August 21, 2026 10:07
@qodo-code-review

qodo-code-review Bot commented Aug 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Unsafe description trim 🐞 Bug ☼ Reliability
Description
hintFor() calls .trim() on json_metadata.description without validating it’s a string, so a
malformed metadata shape can throw and break annotateLanguageHints() (and thus the server
queryFn). This contradicts the “Never throws” behavior promised for language hints and can cause
SSR/prefetch failures for affected rows/pages.
Code

apps/web/src/core/entries/language-hint.ts[R61-64]

+  const raw = entry.body
+    ? "" // a full body is the post page's business; hints are for slim rows
+    : ((entry.json_metadata?.description as string | null | undefined) ?? "").trim();
+  if (raw.length < MIN_DETECT_CHARS) return null;
Relevance

●●● Strong

Recent accepted precedents require runtime guards for untrusted shapes and preserving never-throw
reliability contracts.

PR-#1024
PR-#954

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new code trims description without a runtime type guard, so a non-string value will throw
before hintFor()’s try/catch. The codebase already documents that json_metadata fields are
untrusted and guards description with typeof === "string" elsewhere, indicating this is a real
input possibility.

apps/web/src/core/entries/language-hint.ts[57-70]
apps/web/src/core/entries/slim-entry.ts[85-89]

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/core/entries/language-hint.ts` assumes `entry.json_metadata.description` is always a string and does `(... ?? "").trim()`. In real data, `json_metadata` fields are not trustworthy (other code already defends against this), and a non-string `description` will throw before the internal try/catch, breaking the server-side query.

### Issue Context
The module comment says hint annotation “Never throws”. This currently isn’t guaranteed because `.trim()` executes outside the guarded `try { ... }`.

### Fix Focus Areas
- apps/web/src/core/entries/language-hint.ts[57-71]

### Suggested change
- Read `description` as `unknown` and only trim/use it if `typeof description === "string"`.
- (Optional) Move the raw extraction inside the `try` block or add a small `try/catch` around it to uphold the “never throws” contract.

Example:
```ts
const desc = entry.json_metadata?.description;
const raw = entry.body
 ? ""
 : (typeof desc === "string" ? desc.trim() : "");
```

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



Remediation recommended

2. Hardcoded queryKey: ["hint"] ✗ Dismissed 📘 Rule violation ⚙ Maintainability
Description
The new test uses a hardcoded React Query key array (["hint"]) instead of using a QueryKeys
builder/constant. This can lead to inconsistent cache key conventions and makes later
refactors/invalidation harder.
Code

apps/web/src/specs/core/entries/language-hint.spec.ts[R22-26]

+  it("detects the summary language of slim rows and ships it as slim.lang", async () => {
+    const options = withSlimEntries({
+      queryKey: ["hint"],
+      queryFn: async () => [row(SPANISH), row(ENGLISH)]
+    });
Relevance

●● Moderate

Recent history is mixed: centralized query keys are accepted in some tests but rejected as
unnecessary in others.

PR-#1563
PR-#1565

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2667922 requires using QueryKeys rather than hardcoded literals for React Query
keys. The added test constructs queryKey: ["hint"] directly when calling withSlimEntries(...).

Rule 2667922: Use QueryKeys constants for react-query keys instead of hardcoded literals
apps/web/src/specs/core/entries/language-hint.spec.ts[22-26]

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

## Issue description
A new test passes a hardcoded React Query `queryKey` (`["hint"]`) instead of deriving it from `QueryKeys`.

## Issue Context
Per compliance, query keys should come from `QueryKeys` (imported from `@ecency/sdk`) to keep cache key conventions centralized.

## Fix Focus Areas
- apps/web/src/specs/core/entries/language-hint.spec.ts[22-45]

ⓘ 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: This is a cross-cutting runtime behavior change spanning server detection, client gating, feed merging, lazy loading, and multiple data paths; it carries enough independent integration risk for a careful complete review, but not unusually dense enough to justify redundant passes.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/web/src/specs/core/entries/language-hint.spec.ts
Comment on lines +61 to +64
const raw = entry.body
? "" // a full body is the post page's business; hints are for slim rows
: ((entry.json_metadata?.description as string | null | undefined) ?? "").trim();
if (raw.length < MIN_DETECT_CHARS) 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.

Action required

2. Unsafe description trim 🐞 Bug ☼ Reliability

hintFor() calls .trim() on json_metadata.description without validating it’s a string, so a
malformed metadata shape can throw and break annotateLanguageHints() (and thus the server
queryFn). This contradicts the “Never throws” behavior promised for language hints and can cause
SSR/prefetch failures for affected rows/pages.
Agent Prompt
### Issue description
`apps/web/src/core/entries/language-hint.ts` assumes `entry.json_metadata.description` is always a string and does `(... ?? "").trim()`. In real data, `json_metadata` fields are not trustworthy (other code already defends against this), and a non-string `description` will throw before the internal try/catch, breaking the server-side query.

### Issue Context
The module comment says hint annotation “Never throws”. This currently isn’t guaranteed because `.trim()` executes outside the guarded `try { ... }`.

### Fix Focus Areas
- apps/web/src/core/entries/language-hint.ts[57-71]

### Suggested change
- Read `description` as `unknown` and only trim/use it if `typeof description === "string"`.
- (Optional) Move the raw extraction inside the `try` block or add a small `try/catch` around it to uphold the “never throws” contract.

Example:
```ts
const desc = entry.json_metadata?.description;
const raw = entry.body
  ? ""
  : (typeof desc === "string" ? desc.trim() : "");
```

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

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.

Taken: hintFor now only trims a string description (anything else counts as no text) and the whole hint path, including the per-row annotate, is inside try/catch, so a malformed row carries no hint instead of failing the query. Spec covers undefined, null, number, object and array descriptions.

The Translate chip on a card needs the content language. Every visitor's
browser detected it: one markdown render per card plus the franc-min
detector chunk (about 47 KB gzipped), on idle after every feed and post
view, for the majority of readers whose language matches the content.

The slim step already derives each card's summary on the server, so the
server now detects the language from that same summary once per fetch
and ships it as slim.lang (ISO-639-1, or null when the text is too short
or the detector is unsure). The sample is rendered to plain text with the
same bounds the client applied, so an author description that is only an
image link or markup counts as no text rather than as a language. The
client gate reads the hint, caches it under the summary key as before and
skips both the render and the chunk. Rows the browser fetches itself
(later pages of an infinite feed) carry no hint and keep the on-idle
path, and the feed poll's merge preserves the hint on rows it refreshes;
the post page keeps detecting on the full body. The detector is imported
lazily inside the server branch only, so it never enters a client bundle,
and a failing detector leaves the rows without a hint rather than failing
the query.

Closes #1597
@feruzm
feruzm force-pushed the perf/server-side-lang-hint branch from 34bb849 to 2badef4 Compare August 21, 2026 10:11
@feruzm
feruzm merged commit 8d12503 into develop Aug 21, 2026
8 checks passed
@feruzm
feruzm deleted the perf/server-side-lang-hint branch August 21, 2026 10:23
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.

Per-card language detection loads franc-min (~47 KB gz) for every visitor

1 participant