From 80cae0cc3c82bc31a7f422f996521035b4ad4926 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 20 Aug 2026 12:20:11 +0000 Subject: [PATCH 1/2] Keep the payout fields, not the whole entries, for the wallet total The wallet's pending-earnings figure reads payout_at and pending_payout_value and nothing else, but the bridge answers with whole entries. Measured across four active accounts, the two calls this component makes retain about 660 KB each time the wallet is opened, roughly 100 KB of post bodies and 500 KB of voter records, to produce one number. Projected on arrival it is 2.8 KB. The wire cost is unchanged and cannot be fixed here, since the bridge sends what it sends. What this buys is retained memory, which is what bounds how many renderer replicas fit on a host. The projection answers under its own key. accountPostsPage is shared with the waves composer and the decks user column, both of which consume whole entries, and handing either a projected row would be the fault behind #1556. A test pins that the key is the shared one plus a marker rather than the shared one. --- .../_components/pending-payouts-query.ts | 47 ++++++++++++ .../profile-wallet-pending-earnings.tsx | 10 ++- .../app/profile/pending-payouts-query.spec.ts | 74 +++++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts create mode 100644 apps/web/src/specs/app/profile/pending-payouts-query.spec.ts diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts new file mode 100644 index 0000000000..c5225af5ca --- /dev/null +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts @@ -0,0 +1,47 @@ +import { getAccountPostsQueryOptions } from "@ecency/sdk"; +import { queryOptions } from "@tanstack/react-query"; +import type { Entry } from "@/entities"; + +/** The two fields the pending-earnings total is computed from. */ +export interface PendingPayout { + payout_at: string; + pending_payout_value: string; +} + +const RECENT_LIMIT = 20; + +/** + * Recent posts or comments, reduced to the payout fields on arrival. + * + * The wallet total reads `payout_at` and `pending_payout_value` and nothing + * else, but the bridge answers with whole entries: measured across four active + * accounts, the two calls this component makes retain about 660 KB each time + * the wallet is opened, roughly 100 KB of post bodies and 500 KB of voter + * records, to produce one number. Projecting on arrival keeps 2.8 KB of it. + * + * The wire cost is unchanged: the bridge sends what it sends, and only an + * endpoint of our own or a field on the account could avoid that. What this + * buys is retained memory, which is the thing that bounds how many renderer + * replicas fit on a host. + * + * The key carries its own marker. `accountPostsPage` is read by the waves + * composer and the decks user column, which need whole entries, and handing + * either of them a projected row would be the fault that issue #1556 was. + */ +export function pendingPayoutsQueryOptions(username: string, sort: "posts" | "comments") { + const base = getAccountPostsQueryOptions(username, sort, "", "", RECENT_LIMIT, ""); + const fetchEntries = base.queryFn as (ctx?: unknown) => Promise; + + // Built rather than spread: spreading carries the SDK's own `Entry[]` result + // type, which this deliberately narrows. + return queryOptions({ + queryKey: [...base.queryKey, "pending-payouts"], + queryFn: async (): Promise => { + const entries = (await fetchEntries()) ?? []; + return entries.map((entry) => ({ + payout_at: entry.payout_at, + pending_payout_value: entry.pending_payout_value + })); + } + }); +} diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx index 48a89bb1d9..8da571f2ec 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx @@ -3,10 +3,10 @@ import { FormattedCurrency } from "@/features/shared"; import { getAccountFullQueryOptions, - getAccountPostsQueryOptions, getDynamicPropsQueryOptions, getPointsQueryOptions } from "@ecency/sdk"; +import { pendingPayoutsQueryOptions } from "./pending-payouts-query"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useMemo } from "react"; @@ -29,13 +29,15 @@ export function ProfileWalletPendingEarnings() { enabled: Boolean(username), }); - // Fetch recent posts and comments to calculate potential earnings from active content + // Recent posts and comments, for the earnings still inside their payout + // window. Only the payout fields are kept: see pendingPayoutsQueryOptions for + // what the whole entries were costing to produce one number. const { data: recentPosts } = useQuery({ - ...getAccountPostsQueryOptions(username, "posts", "", "", 20, ""), + ...pendingPayoutsQueryOptions(username, "posts"), enabled: Boolean(username), }); const { data: recentComments } = useQuery({ - ...getAccountPostsQueryOptions(username, "comments", "", "", 20, ""), + ...pendingPayoutsQueryOptions(username, "comments"), enabled: Boolean(username), }); diff --git a/apps/web/src/specs/app/profile/pending-payouts-query.spec.ts b/apps/web/src/specs/app/profile/pending-payouts-query.spec.ts new file mode 100644 index 0000000000..20ae5e80a7 --- /dev/null +++ b/apps/web/src/specs/app/profile/pending-payouts-query.spec.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { QueryKeys } from "@ecency/sdk"; +import type { Entry } from "@/entities"; + +const fetchSpy = vi.hoisted(() => vi.fn()); + +vi.mock("@ecency/sdk", async () => { + const actual = await vi.importActual>("@ecency/sdk"); + const { QueryKeys: keys } = actual as { QueryKeys: typeof QueryKeys }; + return { + ...actual, + getAccountPostsQueryOptions: ( + username: string, + sort: string, + a: string, + p: string, + limit: number, + observer: string + ) => ({ + queryKey: keys.posts.accountPostsPage(username, sort, a, p, limit, observer), + queryFn: fetchSpy + }) + }; +}); + +import { pendingPayoutsQueryOptions } from "@/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query"; + +function entry(overrides: Partial = {}): Entry { + return { + author: "alice", + permlink: "p", + body: "a very long post body that the wallet total has no use for", + active_votes: Array.from({ length: 400 }, (_, i) => ({ voter: `v${i}`, rshares: 1_000_000 })), + payout_at: "2026-08-27T00:00:00", + pending_payout_value: "1.234 HBD", + ...overrides + } as unknown as Entry; +} + +/** + * The wallet total reads two fields and the bridge answers with whole entries, + * about 660 KB per wallet view across the two calls, mostly voter records. + */ +describe("pendingPayoutsQueryOptions", () => { + it("keeps only the payout fields", async () => { + fetchSpy.mockResolvedValueOnce([entry(), entry({ pending_payout_value: "2.000 HBD" })]); + const rows = await pendingPayoutsQueryOptions("alice", "posts").queryFn(); + + expect(rows).toEqual([ + { payout_at: "2026-08-27T00:00:00", pending_payout_value: "1.234 HBD" }, + { payout_at: "2026-08-27T00:00:00", pending_payout_value: "2.000 HBD" } + ]); + for (const row of rows) { + expect(row).not.toHaveProperty("body"); + expect(row).not.toHaveProperty("active_votes"); + } + }); + + it("does not answer under the key that whole-entry readers use", () => { + // The waves composer and the decks user column read accountPostsPage and + // need whole entries; a projected row there would be issue #1556 again. + const shared = QueryKeys.posts.accountPostsPage("alice", "posts", "", "", 20, ""); + const projected = pendingPayoutsQueryOptions("alice", "posts").queryKey; + + expect(projected).not.toEqual(shared); + expect(projected.slice(0, shared.length)).toEqual(shared); + expect(projected[projected.length - 1]).toBe("pending-payouts"); + }); + + it("survives an empty or missing answer", async () => { + fetchSpy.mockResolvedValueOnce(null); + await expect(pendingPayoutsQueryOptions("alice", "comments").queryFn()).resolves.toEqual([]); + }); +}); From 8994fdff3d53b565242a9cc56220a4f22f5d44c7 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 20 Aug 2026 12:59:28 +0000 Subject: [PATCH 2/2] Forward the query context and filter the projection by account The wrapper called the SDK query function with no arguments, so the abort signal React Query hands it never reached the bridge call: a wallet the reader had navigated away from kept downloading and resolving entries, which is what this projection exists to avoid. Also drops entries whose author is not the account whose wallet is open, so a node answering with anything else cannot move the total, and moves the builder to api/queries where app-specific queries live. --- apps/web/src/api/queries/index.ts | 1 + .../src/api/queries/pending-payouts-query.ts | 72 +++++++++++++++++++ .../_components/pending-payouts-query.ts | 47 ------------ .../profile-wallet-pending-earnings.tsx | 2 +- .../pending-payouts-query.spec.ts | 59 ++++++++++++--- 5 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/api/queries/pending-payouts-query.ts delete mode 100644 apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts rename apps/web/src/specs/{app/profile => api}/pending-payouts-query.spec.ts (50%) diff --git a/apps/web/src/api/queries/index.ts b/apps/web/src/api/queries/index.ts index 01862ca566..af1be42a82 100644 --- a/apps/web/src/api/queries/index.ts +++ b/apps/web/src/api/queries/index.ts @@ -3,3 +3,4 @@ export * from "./get-account-posts-feed-query"; export * from "./get-gifs-query"; export * from "./useClientTheme"; export * from "./useHydrated"; +export * from "./pending-payouts-query"; diff --git a/apps/web/src/api/queries/pending-payouts-query.ts b/apps/web/src/api/queries/pending-payouts-query.ts new file mode 100644 index 0000000000..0cea0b9651 --- /dev/null +++ b/apps/web/src/api/queries/pending-payouts-query.ts @@ -0,0 +1,72 @@ +import { getAccountPostsQueryOptions } from "@ecency/sdk"; +import { + queryOptions, + type QueryFunctionContext, + type UseQueryOptions +} from "@tanstack/react-query"; +import type { Entry } from "@/entities"; + +/** The two fields the pending-earnings total is computed from. */ +export interface PendingPayout { + payout_at: string; + pending_payout_value: string; +} + +const RECENT_LIMIT = 20; + +/** + * Recent posts or comments, reduced to the payout fields on arrival. + * + * The wallet total reads `payout_at` and `pending_payout_value` and nothing + * else, but the bridge answers with whole entries: measured across four active + * accounts, the two calls this component makes retain about 660 KB each time + * the wallet is opened, roughly 100 KB of post bodies and 500 KB of voter + * records, to produce one number. Projecting on arrival keeps 2.8 KB of it. + * + * The wire cost is unchanged: the bridge sends what it sends, and only an + * endpoint of our own or a field on the account could avoid that. What this + * buys is retained memory, which is the thing that bounds how many renderer + * replicas fit on a host. + * + * The key carries its own marker. `accountPostsPage` is read by the waves + * composer and the decks user column, which need whole entries, and handing + * either of them a projected row would be the fault that issue #1556 was. + */ +/** Appended to the SDK page key so whole-entry readers cannot pick these up. */ +export const PENDING_PAYOUTS_KEY = "pending-payouts"; + +export function pendingPayoutsQueryOptions( + username: string, + sort: "posts" | "comments" +): UseQueryOptions { + const base = getAccountPostsQueryOptions(username, sort, "", "", RECENT_LIMIT, ""); + const fetchEntries = base.queryFn as ( + ctx: QueryFunctionContext + ) => Promise; + + // Built rather than spread: spreading carries the SDK's own `Entry[]` result + // type, which this deliberately narrows. + // Widened deliberately: the SDK brands its own key tuple, and spreading that + // brand into this key makes it unassignable to the declared return type. + const queryKey: readonly unknown[] = [...base.queryKey, PENDING_PAYOUTS_KEY]; + + return queryOptions({ + queryKey, + // The context is forwarded, not dropped: the SDK query reads `signal` from + // it and hands it to the bridge call, so without this a wallet the reader + // has navigated away from keeps downloading entries nobody will look at, + // which is the opposite of the point. + queryFn: async (ctx): Promise => { + const entries = (await fetchEntries(ctx)) ?? []; + return entries + // The node is asked for one account's posts, and only that account's + // payouts belong in its total. A node answering with anything else, + // which is not hypothetical for this network, must not move the number. + .filter((entry) => entry.author === username) + .map((entry) => ({ + payout_at: entry.payout_at, + pending_payout_value: entry.pending_payout_value + })); + } + }); +} diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts deleted file mode 100644 index c5225af5ca..0000000000 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { getAccountPostsQueryOptions } from "@ecency/sdk"; -import { queryOptions } from "@tanstack/react-query"; -import type { Entry } from "@/entities"; - -/** The two fields the pending-earnings total is computed from. */ -export interface PendingPayout { - payout_at: string; - pending_payout_value: string; -} - -const RECENT_LIMIT = 20; - -/** - * Recent posts or comments, reduced to the payout fields on arrival. - * - * The wallet total reads `payout_at` and `pending_payout_value` and nothing - * else, but the bridge answers with whole entries: measured across four active - * accounts, the two calls this component makes retain about 660 KB each time - * the wallet is opened, roughly 100 KB of post bodies and 500 KB of voter - * records, to produce one number. Projecting on arrival keeps 2.8 KB of it. - * - * The wire cost is unchanged: the bridge sends what it sends, and only an - * endpoint of our own or a field on the account could avoid that. What this - * buys is retained memory, which is the thing that bounds how many renderer - * replicas fit on a host. - * - * The key carries its own marker. `accountPostsPage` is read by the waves - * composer and the decks user column, which need whole entries, and handing - * either of them a projected row would be the fault that issue #1556 was. - */ -export function pendingPayoutsQueryOptions(username: string, sort: "posts" | "comments") { - const base = getAccountPostsQueryOptions(username, sort, "", "", RECENT_LIMIT, ""); - const fetchEntries = base.queryFn as (ctx?: unknown) => Promise; - - // Built rather than spread: spreading carries the SDK's own `Entry[]` result - // type, which this deliberately narrows. - return queryOptions({ - queryKey: [...base.queryKey, "pending-payouts"], - queryFn: async (): Promise => { - const entries = (await fetchEntries()) ?? []; - return entries.map((entry) => ({ - payout_at: entry.payout_at, - pending_payout_value: entry.pending_payout_value - })); - } - }); -} diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx index 8da571f2ec..8b8bdb98b0 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx @@ -6,7 +6,7 @@ import { getDynamicPropsQueryOptions, getPointsQueryOptions } from "@ecency/sdk"; -import { pendingPayoutsQueryOptions } from "./pending-payouts-query"; +import { pendingPayoutsQueryOptions } from "@/api/queries/pending-payouts-query"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useMemo } from "react"; diff --git a/apps/web/src/specs/app/profile/pending-payouts-query.spec.ts b/apps/web/src/specs/api/pending-payouts-query.spec.ts similarity index 50% rename from apps/web/src/specs/app/profile/pending-payouts-query.spec.ts rename to apps/web/src/specs/api/pending-payouts-query.spec.ts index 20ae5e80a7..ec0552d8f2 100644 --- a/apps/web/src/specs/app/profile/pending-payouts-query.spec.ts +++ b/apps/web/src/specs/api/pending-payouts-query.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { QueryKeys } from "@ecency/sdk"; -import type { Entry } from "@/entities"; +import type { QueryFunctionContext } from "@tanstack/react-query"; +import { mockEntry } from "@/specs/test-utils"; const fetchSpy = vi.hoisted(() => vi.fn()); @@ -23,18 +24,29 @@ vi.mock("@ecency/sdk", async () => { }; }); -import { pendingPayoutsQueryOptions } from "@/app/(dynamicPages)/profile/[username]/wallet/_components/pending-payouts-query"; +import { pendingPayoutsQueryOptions } from "@/api/queries/pending-payouts-query"; -function entry(overrides: Partial = {}): Entry { - return { +const votes = Array.from({ length: 400 }, (_, i) => ({ voter: `v${i}`, rshares: 1_000_000 })); + +function entry(overrides: Partial> = {}) { + return mockEntry({ author: "alice", permlink: "p", - body: "a very long post body that the wallet total has no use for", - active_votes: Array.from({ length: 400 }, (_, i) => ({ voter: `v${i}`, rshares: 1_000_000 })), + active_votes: votes as never, payout_at: "2026-08-27T00:00:00", pending_payout_value: "1.234 HBD", ...overrides - } as unknown as Entry; + }); +} + +/** A context of the shape React Query hands a queryFn. */ +function context(signal: AbortSignal): QueryFunctionContext { + return { queryKey: [], signal, meta: undefined } as unknown as QueryFunctionContext; +} + +function run(sort: "posts" | "comments", ctx = context(new AbortController().signal)) { + const { queryFn } = pendingPayoutsQueryOptions("alice", sort); + return (queryFn as (c: QueryFunctionContext) => Promise)(ctx); } /** @@ -44,7 +56,7 @@ function entry(overrides: Partial = {}): Entry { describe("pendingPayoutsQueryOptions", () => { it("keeps only the payout fields", async () => { fetchSpy.mockResolvedValueOnce([entry(), entry({ pending_payout_value: "2.000 HBD" })]); - const rows = await pendingPayoutsQueryOptions("alice", "posts").queryFn(); + const rows = await run("posts"); expect(rows).toEqual([ { payout_at: "2026-08-27T00:00:00", pending_payout_value: "1.234 HBD" }, @@ -60,7 +72,7 @@ describe("pendingPayoutsQueryOptions", () => { // The waves composer and the decks user column read accountPostsPage and // need whole entries; a projected row there would be issue #1556 again. const shared = QueryKeys.posts.accountPostsPage("alice", "posts", "", "", 20, ""); - const projected = pendingPayoutsQueryOptions("alice", "posts").queryKey; + const projected = pendingPayoutsQueryOptions("alice", "posts").queryKey as unknown[]; expect(projected).not.toEqual(shared); expect(projected.slice(0, shared.length)).toEqual(shared); @@ -69,6 +81,33 @@ describe("pendingPayoutsQueryOptions", () => { it("survives an empty or missing answer", async () => { fetchSpy.mockResolvedValueOnce(null); - await expect(pendingPayoutsQueryOptions("alice", "comments").queryFn()).resolves.toEqual([]); + await expect(run("comments")).resolves.toEqual([]); + }); + + it("hands React Query's cancellation context to the request underneath", async () => { + // The SDK query reads `signal` off this context and gives it to the bridge + // call. Dropping the context does not fail anything visibly: the wallet a + // reader has navigated away from just keeps downloading entries, which is + // the opposite of what this projection is for. + fetchSpy.mockResolvedValueOnce([]); + const controller = new AbortController(); + const ctx = context(controller.signal); + + await run("posts", ctx); + + expect(fetchSpy).toHaveBeenCalledWith(ctx); + expect(fetchSpy.mock.calls.at(-1)?.[0]?.signal).toBe(controller.signal); + }); + + it("counts only the account whose wallet this is", async () => { + // One node answering with somebody else's post must not move the total. + fetchSpy.mockResolvedValueOnce([ + entry(), + entry({ author: "mallory", pending_payout_value: "999.000 HBD" }) + ]); + + await expect(run("posts")).resolves.toEqual([ + { payout_at: "2026-08-27T00:00:00", pending_payout_value: "1.234 HBD" } + ]); }); });