Uh oh!
There was an error while loading. Please reload this page.
perf(ssr): strip active_votes from anonymous feed + profile SSR payloads - #1024
Conversation
Greptile SummaryThis PR reduces anonymous SSR payload size by removing eager vote lists where counts remain available. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (5): Last reviewed commit: "fix(vote-strip): also strip keyed maps o..." | Re-trigger Greptile |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
💡 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; |
There was a problem hiding this comment.
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
commented
Jun 26, 2026
|
Warning Review limit reached
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughReact 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 ChangesActive-vote hydration and vote counts
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 winTighten 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 theanycasts 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
📒 Files selected for processing (15)
apps/web/src/app/(dynamicPages)/community/[community]/[tag]/page.tsxapps/web/src/app/(dynamicPages)/community/[community]/page.tsxapps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-warnings.tsxapps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/page.tsxapps/web/src/app/(dynamicPages)/feed/[...sections]/page.tsxapps/web/src/app/(dynamicPages)/profile/[username]/[section]/page.tsxapps/web/src/app/(dynamicPages)/profile/[username]/page.tsxapps/web/src/app/waves/[author]/[permlink]/page.tsxapps/web/src/app/waves/_components/waves-list-item.tsxapps/web/src/app/waves/page.tsxapps/web/src/core/react-query/strip-active-votes.tsapps/web/src/features/shared/discussion/discussion-item.tsxapps/web/src/features/shared/entry-list-item/entry-list-item-muted-content.tsxapps/web/src/specs/core/strip-active-votes.spec.tsapps/web/src/utils/entry-indexability.ts
| 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 }; | ||
| } |
There was a problem hiding this comment.
🚀 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.
| 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.
30e04bb to
8bf5650Compare…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
commented
Jun 26, 2026
Reworked + extended (resolves the earlier do-not-merge): (1) strip is now anonymous-only — logged-in requests keep the full |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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.)
Uh oh!
There was an error while loading. Please reload this page.
… 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.Uh oh!
There was an error while loading. Please reload this page.
…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
…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
What
Hive's
bridge.get_post/get_ranked_posts/get_account_postsreturn the fullactive_votesarray (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:/trending/photography/@good-karma/@taskmaster4450/@ecencyAnonymous / 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 setsentry.active_votes = []across the shapes that occur (single entry, infiniteEntry[]/search-{results}pages, discussion arrays). It returns the state untouched whencurrentUseris set (logged-in keeps the full array), clones rather than mutates (server render + SEO indexability keep full data), and only strips entries that also carrystats.total_votesso the vote count stays hydration-stable.feed/[...sections], already cookie-aware) and the profile account-posts feed (profile/[username]+[section]), gated on theactive_usercookie.revalidate = 300is removed. The cache-policy middleware applies theprofiletierCache-Control(s-maxage=300, swr=3600) by pathname regardless, so the anon variant stays edge-cached on the same window.entry.stats.total_votes(theisHiddenPostcallers + the wave indexability gate), so they don't depend on the stripped array.Why it's safe (verified)
currentUseris set, so logged-in keeps the fullactive_votes;isVotedreads it client-side unchanged.__ec_auth=anon|loggedin(the sameactive_usercookie) and forwards the cookie to origin — anon and logged-in get separate cache entries, never cross-served.x-edge-cache: HIT, ~70-90 ms); cold true-miss is masked bystale-while-revalidate, same as the old ISR window.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
apps/webtypecheck: no new errors. Affected component specs pass (entry-list-item, discussion-item, entry-votes, entry-indexability).Staging checks before merge
active_votesarrays in the anon flight payload.x-edge-cache: HIT, TTFB ~90 ms,Cache-Control: ...s-maxage=300, swr=3600.active_usercookie):active_votesstill full, "you voted" highlight works, lands on a separate__ec_auth=loggedinentry.stats.total_votes); zero hydration-mismatch console warnings.Summary by CodeRabbit
stats.total_votes(falling back to active vote length) for more consistent visibility decisions across entry, discussion, and wave views.