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
65 changes: 44 additions & 21 deletions apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { withSlimEntries } from "@/core/entries/slim-entry";
import type { InfiniteData } from "@tanstack/react-query";

const MAX_PENDING = 20;
const POLL_INTERVAL_MS = 30000;
// Explicit rather than inherited: fetchQuery honours staleTime, so this is what
// actually decides how often the poll reaches the network. It matches the
// app-wide default that has governed this poll all along, stated here so that
// changing the global default cannot silently change this feed's request rate.
const POLL_STALE_TIME_MS = 60000;
const MAX_AVATARS = 5;

interface Props {
Expand Down Expand Up @@ -64,26 +70,30 @@ export function FeedLayout(props: PropsWithChildren<Props>) {
props.observer ?? ""
);

const interval = setInterval(async () => {
const resp = await queryClient.fetchQuery(
// Slim, like the feed this merges into: the merge below spreads these
// rows over the cached ones, so a full row here would put every body
// back into the feed cache 30 seconds after the page loaded.
withSlimEntries(
getPostsRankedQueryOptions(
props.filter,
"",
"",
MAX_PENDING,
props.tag,
props.observer
),
// Own cache identity: this SDK page key is also read by deck columns,
// which render whole posts. The merge below reads the returned value,
// not the cache, so the marker costs nothing here.
{ isolateKey: true }
)
// A hidden tab has nobody to show a "new posts" chip to, and this is not a
// cheap tick: each fetch is a full 20-post ranked page, 257 KB gzipped on
// /trending, and it runs for anonymous readers too. Left running, a tab
// parked in the background all afternoon pulls about 10 MB an hour to
// maintain a count nobody is looking at.
const poll = async () => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
return;
}
// Slim, like the feed this merges into: the merge below spreads these rows
// over the cached ones, so a full row here would put every body back into
// the feed cache 30 seconds after the page loaded.
//
// Own cache identity: this SDK page key is also read by deck columns,
// which render whole posts. The merge below reads the returned value, not
// the cache, so the marker costs nothing here.
const pollOptions = withSlimEntries(
getPostsRankedQueryOptions(props.filter, "", "", MAX_PENDING, props.tag, props.observer),
{ isolateKey: true }
);
const resp = await queryClient.fetchQuery({
...pollOptions,
staleTime: POLL_STALE_TIME_MS
});
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!resp || resp.length === 0) return;

// Update existing entries with latest stats
Expand Down Expand Up @@ -125,9 +135,22 @@ export function FeedLayout(props: PropsWithChildren<Props>) {
if (fresh.length > 0) {
setPending(fresh.slice(0, MAX_PENDING));
}
}, 30000);
};

const interval = setInterval(poll, POLL_INTERVAL_MS);
// Catch up as soon as the reader comes back, rather than making them wait
// out the rest of an interval for a chip that is already out of date.
const onVisible = () => {
if (document.visibilityState === "visible") {
poll();
}
};
document.addEventListener("visibilitychange", onVisible);

return () => clearInterval(interval);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
};
}, [props.filter, props.tag, props.observer]);

const revealNew = () => {
Expand Down
115 changes: 115 additions & 0 deletions apps/web/src/specs/app/feed/feed-poll-visibility.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from "react";
import { render, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Entry } from "@/entities";

const fetchSpy = vi.hoisted(() => vi.fn(async () => [] as Entry[]));

vi.mock("@/utils", async () => ({
...(await vi.importActual<typeof import("@/utils")>("@/utils")),
random: vi.fn(),
getAccessToken: vi.fn(() => "mock-token")
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
vi.mock("@ecency/sdk", async () => ({
...(await vi.importActual<Record<string, unknown>>("@ecency/sdk")),
getPostsRankedQueryOptions: vi.fn(() => ({
queryKey: ["posts", "ranked-page", "poll"],
queryFn: fetchSpy
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}))
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
vi.mock("@/api/queries", async () => ({
...(await vi.importActual<Record<string, unknown>>("@/api/queries")),
usePostsFeedQuery: () => ({ data: undefined, isFetching: false })
}));
vi.mock("@/features/shared/entry-list-content", () => ({ EntryListContent: () => null }));
vi.mock("@/features/shared/linear-progress", () => ({ LinearProgress: () => null }));
vi.mock("@/features/shared/user-avatar", () => ({ UserAvatar: () => null }));

import { FeedLayout } from "@/app/(dynamicPages)/feed/_components/feed-layout";

function setVisibility(state: "visible" | "hidden") {
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
}

function renderFeed() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

1. Singleton cache leaks across tests 🐞 Bug ☼ Reliability

The spec provides a new QueryClient, but FeedLayout polls through the separate global client
returned by getQueryClient(), whose 60-second cache is never cleared. Cached poll data can therefore
suppress fetchSpy calls in reordered, repeated, or watch-mode runs, making the visibility assertions
order-dependent.
Agent Prompt
## Issue description
The feed polling tests create a provider-specific QueryClient, while FeedLayout uses the global client returned by getQueryClient(). Its poll cache survives between tests and can prevent the mocked query function from running while still fresh.

## Issue Context
The mocked poll uses a fixed query key and production polling applies a 60-second staleTime. Clearing Vitest mocks does not clear the global QueryClient cache, so repeated or reordered tests can observe cached results instead of fetchSpy calls.

## Fix Focus Areas
- apps/web/src/specs/app/feed/feed-poll-visibility.spec.tsx[37-64]
- apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx[65-96]
- apps/web/src/core/react-query/index.ts[109-119]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

Valid, and worse than stated: FeedLayout polls through getQueryClient(), the module-level client, so the client the spec passed was never the one under test at all. Its cache outlived each test, and with the poll's staleTime a leftover entry could have satisfied the next test's fetch, making the assertions order-dependent.

Fixed in 2a7f43a: the global client is cleared before and after every test. The gate itself was already verified by removing it and watching the hidden and catch-up tests fail, so the assertions were testing the right thing, but they could have stopped doing so silently.

return render(
<QueryClientProvider client={client}>
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
<FeedLayout tag="" filter="trending" observer="ecency">
<div />
</FeedLayout>
</QueryClientProvider>
);
}

/**
* The poll fetches a full 20-post ranked page, 257 KB gzipped on /trending, and
* it runs for anonymous readers too. A background tab has nobody to show the
* "new posts" chip to, so it should not be paying for one.
*/
describe("feed poll", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchSpy.mockClear();
setVisibility("visible");
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

it("polls while the tab is visible", async () => {
renderFeed();
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000);
});
expect(fetchSpy).toHaveBeenCalled();
});

it("does not poll while the tab is hidden", async () => {
renderFeed();
setVisibility("hidden");
fetchSpy.mockClear();

await act(async () => {
await vi.advanceTimersByTimeAsync(5 * 60_000);
});

expect(fetchSpy).not.toHaveBeenCalled();
});

it("catches up as soon as the reader comes back", async () => {
renderFeed();
setVisibility("hidden");
await act(async () => {
await vi.advanceTimersByTimeAsync(5 * 60_000);
});
fetchSpy.mockClear();

await act(async () => {
setVisibility("visible");
await vi.advanceTimersByTimeAsync(0);
});

// No waiting out the rest of an interval for a chip that is already stale.
expect(fetchSpy).toHaveBeenCalled();
});

it("stops listening once the feed unmounts", async () => {
const { unmount } = renderFeed();
unmount();
fetchSpy.mockClear();

await act(async () => {
setVisibility("visible");
await vi.advanceTimersByTimeAsync(2 * 60_000);
});

expect(fetchSpy).not.toHaveBeenCalled();
});
});
Loading