Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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
74 changes: 51 additions & 23 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,27 +70,35 @@ 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.
// A poll already awaiting fetchQuery outlives clearInterval, and would then
// write pending/extra state into an unmounted feed.
let cancelled = false;

const poll = async (): Promise<void> => {
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
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 }
);
if (!resp || resp.length === 0) return;
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 (cancelled || !resp || resp.length === 0) return;

// Update existing entries with latest stats
queryClient.setQueryData<InfiniteData<Entry[] | SearchResponse, unknown>>(queryKey, (old) => {
Expand Down Expand Up @@ -125,9 +139,23 @@ export function FeedLayout(props: PropsWithChildren<Props>) {
if (fresh.length > 0) {
setPending(fresh.slice(0, MAX_PENDING));
}
}, 30000);

return () => clearInterval(interval);
};

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 = (): void => {
if (document.visibilityState === "visible") {
poll();
}
};
document.addEventListener("visibilitychange", onVisible);

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

const revealNew = () => {
Expand Down
163 changes: 163 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,163 @@
import React from "react";
import { act, type RenderResult } from "@testing-library/react";
import "@testing-library/jest-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { getQueryClient } from "@/core/react-query";
import { renderWithQueryClient } from "@/specs/test-utils";
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";

const ORIGINAL_VISIBILITY = Object.getOwnPropertyDescriptor(
Document.prototype,
"visibilityState"
);

function setVisibility(state: "visible" | "hidden"): void {
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
}

function restoreVisibility(): void {
// The property is defined on Document.prototype, not on the instance, so the
// test-defined own property has to be deleted or it leaks into later suites.
delete (document as unknown as Record<string, unknown>).visibilityState;
if (ORIGINAL_VISIBILITY) {
Object.defineProperty(Document.prototype, "visibilityState", ORIGINAL_VISIBILITY);
}
}

function renderFeed(): RenderResult {
return renderWithQueryClient(
<FeedLayout tag="" filter="trending" observer="ecency">
<div />
</FeedLayout>,
{
queryClient: new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } }
})
}
);
}

/**
* 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();
// FeedLayout polls through getQueryClient(), the module-level client, not
// the one the provider holds. Its cache outlives a test, and with the poll's
// staleTime a leftover entry would satisfy the next test's fetch and make
// these assertions depend on execution order.
getQueryClient().clear();
setVisibility("visible");
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
getQueryClient().clear();
restoreVisibility();
});

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();
});

it("does not touch state from a poll still in flight at unmount", async () => {
// clearInterval stops the next tick but not one already awaiting fetchQuery.
let release: (v: Entry[]) => void = () => {};
fetchSpy.mockImplementationOnce(
() => new Promise<Entry[]>((resolve) => { release = resolve; })
);
const errors: unknown[] = [];
const onError = (e: ErrorEvent) => errors.push(e.error ?? e.message);
window.addEventListener("error", onError);

const { unmount } = renderFeed();
await act(async () => {
await vi.advanceTimersByTimeAsync(31_000);
});
unmount();

await act(async () => {
release([{ author: "alice", permlink: "p", stats: {} } as unknown as Entry]);
await vi.advanceTimersByTimeAsync(0);
});

window.removeEventListener("error", onError);
expect(errors).toEqual([]);
});
});
Loading