Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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") {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
const base = getAccountPostsQueryOptions(username, sort, "", "", RECENT_LIMIT, "");
const fetchEntries = base.queryFn as (ctx?: unknown) => Promise<Entry[] | null | undefined>;
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

// Built rather than spread: spreading carries the SDK's own `Entry[]` result
// type, which this deliberately narrows.
return queryOptions({
queryKey: [...base.queryKey, "pending-payouts"],
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
queryFn: async (): Promise<PendingPayout[]> => {
const entries = (await fetchEntries()) ?? [];

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 Forward the query context to preserve request cancellation

When these wallet queries are cancelled because the user navigates away, changes profiles, or code calls cancelQueries, this wrapper discards React Query's context and invokes fetchEntries() without its abort signal. The wrapped SDK query explicitly forwards that signal to getAccountPosts (packages/sdk/src/modules/posts/queries/get-account-posts-query-options.ts, lines 83–97), so the newly wrapped requests now continue downloading and retaining the full post/vote payload after their consumer is gone, undermining the memory reduction this change targets. Accept the query context here and pass it through to the SDK query function.

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.

Correct, and fixed in 8994fdf. The wrapper now takes React Query's context and passes it straight through, so signal reaches getAccountPosts again. Covered by a test that fails if the argument is dropped.

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 Query cancellation context is discarded

When the wallet unmounts during either request, React Query aborts its signal, but this wrapper calls fetchEntries without forwarding that context, so the bridge RPC and nested post-resolution work continue until completion or timeout.

Suggested change
queryFn: async (): Promise<PendingPayout[]> => {
const entries = (await fetchEntries()) ?? [];
queryFn: async (ctx): Promise<PendingPayout[]> => {
const entries = (await fetchEntries(ctx)) ?? [];

Fix in Claude Code

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.

Fixed in 8994fdf, along the lines of the suggestion. Added a test asserting the exact context object reaches the SDK query function and that its signal is the one the caller supplied.

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
return entries.map((entry) => ({
payout_at: entry.payout_at,
pending_payout_value: entry.pending_payout_value
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}));
}
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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),
});

Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/specs/app/profile/pending-payouts-query.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>("@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> = {}): Entry {
return {
author: "alice",
permlink: "p",
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
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([]);
});
});
Loading