Skip to content

perf(ssr): strip active_votes from anonymous feed + profile SSR payloads - #1024

Merged
feruzm merged 4 commits into
developfrom
bugfix/strip-active-votes-ssr-dehydration
Jun 26, 2026
Merged

perf(ssr): strip active_votes from anonymous feed + profile SSR payloads#1024
feruzm merged 4 commits into
developfrom
bugfix/strip-active-votes-ssr-dehydration

Conversation

@feruzm

@feruzmferuzm commented Jun 26, 2026

Copy link
Copy Markdown
Member

What

Hive's bridge.get_post / get_ranked_posts / get_account_posts return the full active_votes array (a { voter, rshares } record per vote), which gets dehydrated into the SSR payload. On busy tag feeds and profiles that's a large, anon-irrelevant chunk — measured live:

Pageactive_votes in anon SSR
Tag feed /trending/photography~580 KB (~half the page)
/@good-karma542 KB (57% of page, 12.9k voters)
/@taskmaster4450594 KB (57%, 13.8k voters)
/@ecency301 KB (41%, 7k voters)

Anonymous / crawler visitors — the bulk of these SEO pages' traffic — never read it (the "you voted" highlight is logged-in-only, and the votes dialog already fetches the list on demand). So this strips it for anonymous requests only.

How

  • stripActiveVotesFromDehydratedState(state, currentUser?) (core/react-query/strip-active-votes.ts) walks the dehydrated queries and sets entry.active_votes = [] across the shapes that occur (single entry, infinite Entry[]/search-{results} pages, discussion arrays). It returns the state untouched when currentUser is set (logged-in keeps the full array), clones rather than mutates (server render + SEO indexability keep full data), and only strips entries that also carry stats.total_votes so the vote count stays hydration-stable.
  • Applied at the dehydrate boundary of the tag/filter feed (feed/[...sections], already cookie-aware) and the profile account-posts feed (profile/[username] + [section]), gated on the active_user cookie.
  • The profile pages become dynamic (cookie read), so the ISR revalidate = 300 is removed. The cache-policy middleware applies the profile tier Cache-Control (s-maxage=300, swr=3600) by pathname regardless, so the anon variant stays edge-cached on the same window.
  • Vote-count consumers routed through entry.stats.total_votes (the isHiddenPost callers + the wave indexability gate), so they don't depend on the stripped array.

Why it's safe (verified)

  • Logged-in unaffected: the helper early-returns when currentUser is set, so logged-in keeps the full active_votes; isVoted reads it client-side unchanged.
  • Edge isolation: the CF worker keys its HTML cache on __ec_auth=anon|loggedin (the same active_user cookie) and forwards the cookie to origin — anon and logged-in get separate cache entries, never cross-served.
  • TTFB preserved: a dynamic+anon route warm-caches at the worker (verified live: x-edge-cache: HIT, ~70-90 ms); cold true-miss is masked by stale-while-revalidate, same as the old ISR window.
  • UX neutral: counts via stats.total_votes; the fix(profile): paint profile card above the fold without a hydration-gated fade #1023 profile-card LCP fix is SSR-byte-identical; hydration unchanged (clone).

Validation

  • New strip-util spec (single entry / infinite / search / discussion shapes, clone-not-mutate, skip-without-stats, logged-in no-op, anon strip).
  • apps/web typecheck: no new errors. Affected component specs pass (entry-list-item, discussion-item, entry-votes, entry-indexability).

Staging checks before merge

  1. Anon HTML size before/after on a high-vote profile + tag feed — expect ~40-57% smaller; confirm no active_votes arrays in the anon flight payload.
  2. Anon warm edge: 2nd curl shows x-edge-cache: HIT, TTFB ~90 ms, Cache-Control: ...s-maxage=300, swr=3600.
  3. Anon cold TTFB bounded (few hundred ms, not multi-second).
  4. Logged-in (active_user cookie): active_votes still full, "you voted" highlight works, lands on a separate __ec_auth=loggedin entry.
  5. Vote counts correct on a high-vote post (from stats.total_votes); zero hydration-mismatch console warnings.

Summary by CodeRabbit

  • New Features
    • Improved server-side hydration for feeds and profiles to avoid sending active vote details to the browser for anonymous viewing.
  • Bug Fixes
    • Hidden-content checks and wave quality-gate logic now prefer stats.total_votes (falling back to active vote length) for more consistent visibility decisions across entry, discussion, and wave views.
    • Corrected hydration behavior across multiple feed/search result layouts to keep visibility rules accurate after loading.
  • Tests
    • Added coverage for stripping active votes from dehydrated query data across supported shapes.

@greptile-apps

greptile-appsBot commented Jun 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR reduces anonymous SSR payload size by removing eager vote lists where counts remain available. The main changes are:

  • Anonymous feed and profile hydration now strips active_votes.
  • Logged-in hydration keeps full vote data.
  • Search result, infinite page, array, single entry, and keyed discussion shapes are handled.
  • Vote-count consumers now read preserved count fields before falling back to active_votes.length.
  • Tests cover the supported stripping shapes and logged-in no-op behavior.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The latest stripping helper covers the previously missed search and keyed discussion payload shapes.
  • The added tests exercise the important anonymous and logged-in paths.

Important Files Changed

FilenameOverview
apps/web/src/core/react-query/strip-active-votes.tsAdds the dehydration transformer and covers the search-result and keyed-discussion shapes that can carry large vote arrays.
apps/web/src/specs/core/strip-active-votes.spec.tsAdds tests for anonymous stripping, logged-in pass-through, cloning, infinite pages, search results, arrays, and keyed maps.
apps/web/src/app/(dynamicPages)/feed/[...sections]/page.tsxApplies anonymous-only vote stripping at the feed hydration boundary.
apps/web/src/app/(dynamicPages)/profile/[username]/page.tsxApplies anonymous-only vote stripping to the profile hydration payload.
apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsxApplies anonymous-only vote stripping to profile section hydration.

Reviews (5): Last reviewed commit: "fix(vote-strip): also strip keyed maps o..." | Re-trigger Greptile

Comment threadapps/web/src/core/react-query/strip-active-votes.ts
Comment threadapps/web/src/core/react-query/strip-active-votes.ts Outdated

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:30e04bbbdb

ℹ️ 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".

}

function stripEntry<T>(value: T): T {
return isStripableEntry(value) ? ({ ...value, active_votes: [] } as T) : value;

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 the current user's vote before stripping votes

On logged-in SSR pages where the hydrated entry has been stripped and the user clicks the vote button before the entry refetch finishes, this empties the only data EntryVoteBtn uses to identify an existing vote (isVoted from entry.active_votes). Because getPreviousVote() only calls getEntryActiveVotesQueryOptions when isVoted is already true (apps/web/src/features/shared/entry-vote-btn/index.tsx:105-134), previously-voted posts open as an unvoted slider with no prior weight and can submit an unintended overwrite; preserving the current user's vote or fetching it unconditionally before editing would avoid the race.

Useful? React with 👍 / 👎.

@feruzm

Copy link
Copy Markdown
MemberAuthor

⚠️ Do not merge as-is. Verification found this breaks the isVoted ("you voted") indicator for logged-in users: active_votes is the only source of a user's own vote status (no per-observer vote field on the Entry), and the global QueryClient uses refetchOnMount: false + staleTime: 60s, so a stripped entry never refetches to restore it. SSR is anonymous, so the server can't personalize the kept votes either. Preserving the vote-state indicator would require a new per-user vote-status mechanism (API/SDK), which is a much larger change than the payload win justifies. Recommending we close this unless we pursue that.

@coderabbitai

coderabbitaiBot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

More reviews will be available in 52 minutes and 59 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: edf3cd01-3388-41a7-9dca-55b86310fb3b

📥 Commits

Reviewing files that changed from the base of the PR and between f991fc7 and 04e9a94.

📒 Files selected for processing (2)
  • apps/web/src/core/react-query/strip-active-votes.ts
  • apps/web/src/specs/core/strip-active-votes.spec.ts
📝 Walkthrough

Walkthrough

React Query hydration now strips active votes from dehydrated state for anonymous SSR requests, while logged-in requests keep the original cache. Hidden-state and quality-gate checks now prefer stats.total_votes and fall back to active_votes length.

Changes

Active-vote hydration and vote counts

Layer / File(s)Summary
Strip dehydrated active votes
apps/web/src/core/react-query/strip-active-votes.ts, apps/web/src/specs/core/strip-active-votes.spec.ts
Adds stripActiveVotesFromDehydratedState(...) for dehydrated query data shapes and Vitest coverage for anonymous vs logged-in hydration, referential identity, and non-entry passthrough.
Use the transformer in SSR pages
apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx, apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx, apps/web/src/app/(dynamicPages)/feed/[...sections]/page.tsx
Profile and feed pages read the active-user cookie where needed and pass dehydrate(getQueryClient()) through stripActiveVotesFromDehydratedState(...) before HydrationBoundary.
Prefer total_votes in hidden checks
apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-warnings.tsx, apps/web/src/features/shared/discussion/discussion-item.tsx, apps/web/src/features/shared/entry-list-item/entry-list-item-muted-content.tsx, apps/web/src/app/waves/_components/waves-list-item.tsx, apps/web/src/utils/entry-indexability.ts
isHiddenPost callers and passesWaveQualityGate now use stats.total_votes first, then active_votes, with updated memo dependencies.

Sequence Diagram(s)

sequenceDiagram
participant SSRPage
participant cookies
participant stripActiveVotesFromDehydratedState
participant HydrationBoundary
SSRPage->>cookies: read ACTIVE_USER_COOKIE_NAME
SSRPage->>stripActiveVotesFromDehydratedState: dehydrate(getQueryClient()), loggedInUser
stripActiveVotesFromDehydratedState-->>SSRPage: transformed DehydratedState
SSRPage->>HydrationBoundary: hydrate with transformed state
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ecency/vision-next#757: Shares the same hidden-state call sites, updating the inputs used to decide when entries are treated as hidden.

Suggested labels

patch

Poem

🐰 I hopped through the cache with a sprinkle of dew,
and hid the active votes where the soft carrots grew.
Totals led the lantern, bright and neat,
while hydration burrowed warm beneath my feet.
Nibble, nibble—done!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 15.79% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title accurately summarizes the main change: stripping active_votes from anonymous SSR payloads on feed and profile pages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/strip-active-votes-ssr-dehydration

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.

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/web/src/specs/core/strip-active-votes.spec.ts (1)

5-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tighten the new fixture helpers to real types.

These helpers opt the suite out of strict-mode checks with Record<string, any> / as any, so the test can miss contract drift in the exact entry/query shapes this transformer depends on. Please replace the any casts with a narrow typed fixture/helper. As per coding guidelines, **/*.{ts,tsx}: TypeScript strict mode is enabled; all new code should include proper types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/specs/core/strip-active-votes.spec.ts` around lines 5 - 30, The
new test fixtures are bypassing strict typing by using Record<string, any> and
as any, which can hide shape mismatches in the strip-active-votes transformer
inputs. Tighten the helper in entry to use a narrow typed fixture that matches
the post entry contract, and type dehydrated with the real DehydratedState/query
shape instead of casting state and query objects to any. Keep the helper names
entry and dehydrated, and preserve only the fields this spec actually needs
while staying fully type-safe.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/core/react-query/strip-active-votes.ts`:
- Around line 54-61: The stripPage() helper in strip-active-votes only handles
page objects with a results array, so the items-based search-page shape is
missed. Update stripPage() to recognize and rewrite both items and results by
passing either array through stripEntryArray and preserving the original object
when unchanged. Also add a spec covering the items shape to verify active_votes
is stripped for dehydrated search queries.
---
Nitpick comments:
In `@apps/web/src/specs/core/strip-active-votes.spec.ts`:
- Around line 5-30: The new test fixtures are bypassing strict typing by using
Record<string, any> and as any, which can hide shape mismatches in the
strip-active-votes transformer inputs. Tighten the helper in entry to use a
narrow typed fixture that matches the post entry contract, and type dehydrated
with the real DehydratedState/query shape instead of casting state and query
objects to any. Keep the helper names entry and dehydrated, and preserve only
the fields this spec actually needs while staying fully type-safe.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a5b4fd9-d0bf-4172-ade0-a2e3965960a8

📥 Commits

Reviewing files that changed from the base of the PR and between 9fed8c7 and 30e04bb.

📒 Files selected for processing (15)
  • apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsx
  • apps/web/src/app/(dynamicPages)/community/[community]/page.tsx
  • apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-warnings.tsx
  • apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsx
  • apps/web/src/app/(dynamicPages)/feed/[...sections]/page.tsx
  • apps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsx
  • apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx
  • apps/web/src/app/waves/[author]/[permlink]/page.tsx
  • apps/web/src/app/waves/_components/waves-list-item.tsx
  • apps/web/src/app/waves/page.tsx
  • apps/web/src/core/react-query/strip-active-votes.ts
  • apps/web/src/features/shared/discussion/discussion-item.tsx
  • apps/web/src/features/shared/entry-list-item/entry-list-item-muted-content.tsx
  • apps/web/src/specs/core/strip-active-votes.spec.ts
  • apps/web/src/utils/entry-indexability.ts

Comment on lines +54 to +61
function stripPage(page: unknown): unknown {
if (Array.isArray(page)) {
return stripEntryArray(page);
}
if (page && typeof page === "object" && Array.isArray((page as { results?: unknown }).results)) {
const results = stripEntryArray((page as { results: unknown[] }).results);
return results === (page as { results: unknown[] }).results ? page : { ...page, results };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Handle the items search-page shape here too.

stripPage() only rewrites { results: [...] }, but the community tag page already flattens search data from items ?? results. If a dehydrated search query uses items, the full active_votes array still gets serialized and this payload reduction never applies on that route. Please strip both keys and add a matching spec.

Suggested fix
 function stripPage(page: unknown): unknown {
if (Array.isArray(page)) {
return stripEntryArray(page);
}
- if (page && typeof page === "object" && Array.isArray((page as { results?: unknown }).results)) {- const results = stripEntryArray((page as { results: unknown[] }).results);- return results === (page as { results: unknown[] }).results ? page : { ...page, results };+ if (page && typeof page === "object") {+ const searchPage = page as { results?: unknown[]; items?: unknown[] };++ if (Array.isArray(searchPage.results)) {+ const results = stripEntryArray(searchPage.results);+ return results === searchPage.results ? page : { ...page, results };+ }++ if (Array.isArray(searchPage.items)) {+ const items = stripEntryArray(searchPage.items);+ return items === searchPage.items ? page : { ...page, items };+ }
}
return page;
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
functionstripPage(page: unknown): unknown{
if(Array.isArray(page)){
returnstripEntryArray(page);
}
if(page&&typeofpage==="object"&&Array.isArray((pageas{results?: unknown}).results)){
constresults=stripEntryArray((pageas{results: unknown[]}).results);
returnresults===(pageas{results: unknown[]}).results ? page : { ...page, results };
}
functionstripPage(page: unknown): unknown{
if(Array.isArray(page)){
returnstripEntryArray(page);
}
if(page&&typeofpage==="object"){
constsearchPage=pageas{results?: unknown[];items?: unknown[]};
if(Array.isArray(searchPage.results)){
constresults=stripEntryArray(searchPage.results);
returnresults===searchPage.results ? page : { ...page, results };
}
if(Array.isArray(searchPage.items)){
constitems=stripEntryArray(searchPage.items);
returnitems===searchPage.items ? page : { ...page, items };
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/core/react-query/strip-active-votes.ts` around lines 54 - 61,
The stripPage() helper in strip-active-votes only handles page objects with a
results array, so the items-based search-page shape is missed. Update
stripPage() to recognize and rewrite both items and results by passing either
array through stripEntryArray and preserving the original object when unchanged.
Also add a spec covering the items shape to verify active_votes is stripped for
dehydrated search queries.

The tag/filter feed (feed/[...sections]) dehydrates the full active_votes array
for every post — measured ~580KB (about half the page) on a tag feed such as
/trending/photography. Anonymous and crawler visitors — the bulk of these SEO
pages' traffic — never read it: the "you voted" state is only needed for
logged-in users, and the votes dialog already fetches the full list on demand.
Strip active_votes for ANONYMOUS requests only. The feed page already reads the
active_user cookie; when it is absent (anon / crawler) the dehydrated entries
get active_votes = [] (clone; only entries that also carry stats.total_votes, so
the vote count stays hydration-stable). Logged-in requests keep the full array
unchanged — isVoted (read client-side after auth) is never affected, and the
logged-in feed cache variant is private/no-store anyway.
Also routes the vote-count consumers (isHiddenPost callers + the wave
indexability gate) through entry.stats.total_votes — the count source — so they
no longer depend on the array length.
@feruzm
feruzmforce-pushed the bugfix/strip-active-votes-ssr-dehydration branch from 30e04bb to 8bf5650CompareJune 26, 2026 11:30
…s feed
The /@author profile feed (get_account_posts) dehydrates the full active_votes
array per post — measured 40-57% of the page on active profiles (@Good-Karma
542KB, @taskmaster4450 594KB, @ecency 301KB). Strip it for anonymous requests,
the same way as the tag feed.
profile/[username]/page.tsx and [section]/page.tsx now read the active_user
cookie and pass it to stripActiveVotesFromDehydratedState. Reading the cookie
makes these routes dynamic, so the ISR `revalidate = 300` is removed — but the
cache-policy middleware applies the `profile` tier Cache-Control
(s-maxage=300, stale-while-revalidate=3600) by pathname, so the anon variant
stays edge-cached on the same refresh window (verified: a dynamic+anon route
warm-caches at the worker — x-edge-cache HIT, ~70-90ms TTFB). The CF worker
bifurcates the HTML cache by __ec_auth=anon|loggedin, so logged-in requests keep
the full array and the "you voted" highlight is unaffected.
@feruzm

Copy link
Copy Markdown
MemberAuthor

Reworked + extended (resolves the earlier do-not-merge): (1) strip is now anonymous-only — logged-in requests keep the full active_votes, so isVoted is unaffected; (2) extended to the profile account-posts feed (/@author), which measured 40-57% of the anon page (e.g. @Good-Karma 542KB, @taskmaster4450 594KB). Profile pages become dynamic; verified a dynamic+anon route warm-caches at the worker (x-edge-cache HIT ~70-90ms) so TTFB is preserved, and the CF worker isolates anon vs logged-in via __ec_auth. No worker/nginx/cache-policy changes needed.

@feruzmferuzm changed the title perf(ssr): drop eager active_votes from dehydrated entries on entry/feed pagesperf(ssr): strip active_votes from anonymous feed + profile SSR payloadsJun 26, 2026
Comment threadapps/web/src/core/react-query/strip-active-votes.ts Outdated
Comment threadapps/web/src/core/react-query/strip-active-votes.ts
…al_votes
Greptile review: SearchResult entries hold the vote count on a top-level
`total_votes` rather than `stats.total_votes`, so isStripableEntry skipped them
and their full active_votes survived in the anonymous payload. Accept either
count field as the strip guard. SearchListItem renders only `total_votes`
(never active_votes), so this stays hydration-stable.
(The other flagged shape — a keyed discussion object — is a non-issue: the
discussion query's queryFn returns `Array.from(Object.values(response))`, an
Entry[] array, which the util's array branch already strips.)
Comment threadapps/web/src/core/react-query/strip-active-votes.ts
… shape)
Greptile: a query dehydrated as a keyed object of entries
({ "author/permlink": Entry, ... }) hit the single-entry fallback and passed
through with full active_votes. Add a stripKeyedEntries fallback that strips any
object value which is itself a stripable entry and leaves non-entry objects
untouched. Robustness/future-proofing — no such shape is dehydrated on the
stripped pages today (the discussion query already returns an Entry[] array via
Object.values), but the helper now covers it.
@feruzm
feruzm merged commit a267bda into developJun 26, 2026
6 checks passed
@feruzm
feruzm deleted the bugfix/strip-active-votes-ssr-dehydration branch June 26, 2026 13:34
feruzm added a commit that referenced this pull request Jul 28, 2026
…ity and wave pages
Anonymous visitors never read active_votes — isVoted is logged-in-only and the
votes dialog fetches the voter list on demand — yet it was up to a third of the
document on community pages and tens of KB on high-vote posts.
The strip from #1024 / #1025 only ever covered feed and profile. This extends it
to the entry, community and wave routes, and adds a metadata fix on the way.
metadata copy: generateMetadata resolves the entry through
condenser_api.get_content, the only source of root_author / root_permlink
(bridge.get_post returns them empty, and the canonical logic needs them to point
a depth>=2 reply at its discussion root). That fetch landed in the query cache
the page dehydrates, so a whole second entry was serialized to the client just to
build <head> tags. Excluded at the dehydration boundary, which is deterministic —
generateMetadata and the page render share a request with no guaranteed order.
identity: SSR data reaches the client through two channels, the dehydrated query
state and props in the RSC tree, and Flight dedupes by REFERENCE. Stripping each
channel separately yields distinct clones and serializes every post body twice,
which on a low-vote page costs more than the voter arrays save. So the new
stripAnonEntryCacheInPlace rewrites the cache and returns the STORED object,
which the page then renders — one reference, one copy.
That return value matters: setQueryData applies structural sharing
(replaceEqualDeep) and stores a THIRD object that is neither the previous value
nor the clone handed to it, so using the clone silently reintroduces the
duplicate. An earlier revision compensated with a size heuristic; with identity
correct the duplication is gone and the heuristic was deleted.
net_votes is deliberately not accepted as a surviving vote count: it is upvotes
minus downvotes, not a voter count (848 voters vs 820, 453 vs 423), and
entry-votes would fall through to it and display the smaller number.
Measured on a local production build, anonymous vs an active_user cookie:
community /created/hive-125125 -175,463 (35%), /trending/hive-105017 -209,033
(39%), /created/hive-167922 -226,509 (36%); an 846-voter post -42,327; a 3-voter
reply -147 with no growth. Every anonymous render carries 0 voter records and
exactly one active_votes array, counts still display, and logged-in renders keep
the full arrays so isVoted works. Existing feed and profile strips unaffected.
Fixes#1259Fixes#1261
feruzm added a commit that referenced this pull request Jul 28, 2026
…ity and wave pages
Anonymous visitors never read active_votes — isVoted is logged-in-only and the
votes dialog fetches the voter list on demand — yet it was up to a third of the
document on community pages and tens of KB on high-vote posts.
The strip from #1024 / #1025 only ever covered feed and profile. This extends it
to the entry, community and wave routes, and adds a metadata fix on the way.
metadata copy: generateMetadata resolves the entry through
condenser_api.get_content, the only source of root_author / root_permlink
(bridge.get_post returns them empty, and the canonical logic needs them to point
a depth>=2 reply at its discussion root). That fetch landed in the query cache
the page dehydrates, so a whole second entry was serialized to the client just to
build <head> tags. Excluded at the dehydration boundary, which is deterministic —
generateMetadata and the page render share a request with no guaranteed order.
identity: SSR data reaches the client through two channels, the dehydrated query
state and props in the RSC tree, and Flight dedupes by REFERENCE. Stripping each
channel separately yields distinct clones and serializes every post body twice,
which on a low-vote page costs more than the voter arrays save. So the new
stripAnonEntryCacheInPlace rewrites the cache and returns the STORED object,
which the page then renders — one reference, one copy.
That return value matters: setQueryData applies structural sharing
(replaceEqualDeep) and stores a THIRD object that is neither the previous value
nor the clone handed to it, so using the clone silently reintroduces the
duplicate. An earlier revision compensated with a size heuristic; with identity
correct the duplication is gone and the heuristic was deleted.
net_votes is deliberately not accepted as a surviving vote count: it is upvotes
minus downvotes, not a voter count (848 voters vs 820, 453 vs 423), and
entry-votes would fall through to it and display the smaller number.
Measured on a local production build, anonymous vs an active_user cookie:
community /created/hive-125125 -175,463 (35%), /trending/hive-105017 -209,033
(39%), /created/hive-167922 -226,509 (36%); an 846-voter post -42,327; a 3-voter
reply -147 with no growth. Every anonymous render carries 0 voter records and
exactly one active_votes array, counts still display, and logged-in renders keep
the full arrays so isVoted works. Existing feed and profile strips unaffected.
Fixes#1259Fixes#1261
Sign up for freeto 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.

1 participant

@feruzm