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/profile-wallet-pending-earnings.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/wallet/_components/profile-wallet-pending-earnings.tsx index 48a89bb1d9..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 @@ -3,10 +3,10 @@ import { FormattedCurrency } from "@/features/shared"; import { getAccountFullQueryOptions, - getAccountPostsQueryOptions, getDynamicPropsQueryOptions, getPointsQueryOptions } from "@ecency/sdk"; +import { pendingPayoutsQueryOptions } from "@/api/queries/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/api/pending-payouts-query.spec.ts b/apps/web/src/specs/api/pending-payouts-query.spec.ts new file mode 100644 index 0000000000..ec0552d8f2 --- /dev/null +++ b/apps/web/src/specs/api/pending-payouts-query.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { QueryKeys } from "@ecency/sdk"; +import type { QueryFunctionContext } from "@tanstack/react-query"; +import { mockEntry } from "@/specs/test-utils"; + +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 "@/api/queries/pending-payouts-query"; + +const votes = Array.from({ length: 400 }, (_, i) => ({ voter: `v${i}`, rshares: 1_000_000 })); + +function entry(overrides: Partial> = {}) { + return mockEntry({ + author: "alice", + permlink: "p", + active_votes: votes as never, + payout_at: "2026-08-27T00:00:00", + pending_payout_value: "1.234 HBD", + ...overrides + }); +} + +/** 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); +} + +/** + * 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 run("posts"); + + 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 as unknown[]; + + 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(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" } + ]); + }); +});