diff --git a/apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx b/apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx index dd5c231815..ac11445f18 100644 --- a/apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx +++ b/apps/web/src/app/(dynamicPages)/feed/_components/feed-layout.tsx @@ -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 { @@ -64,27 +70,35 @@ export function FeedLayout(props: PropsWithChildren) { 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 => { + 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 + }); + if (cancelled || !resp || resp.length === 0) return; // Update existing entries with latest stats queryClient.setQueryData>(queryKey, (old) => { @@ -125,9 +139,23 @@ export function FeedLayout(props: PropsWithChildren) { 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 = () => { diff --git a/apps/web/src/specs/app/feed/feed-poll-visibility.spec.tsx b/apps/web/src/specs/app/feed/feed-poll-visibility.spec.tsx new file mode 100644 index 0000000000..7b6e4c1ccf --- /dev/null +++ b/apps/web/src/specs/app/feed/feed-poll-visibility.spec.tsx @@ -0,0 +1,212 @@ +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("@/utils")), + random: vi.fn(), + getAccessToken: vi.fn(() => "mock-token") +})); +vi.mock("@ecency/sdk", async () => { + const actual = await vi.importActual>("@ecency/sdk"); + const { QueryKeys } = actual as { QueryKeys: typeof import("@ecency/sdk").QueryKeys }; + return { + ...actual, + getPostsRankedQueryOptions: vi.fn( + (sort: string, a: string, p: string, limit: number, tag: string, observer: string) => ({ + queryKey: QueryKeys.posts.postsRankedPage(sort, a, p, limit, tag, observer), + queryFn: fetchSpy + }) + ) + }; +}); +vi.mock("@/api/queries", async () => ({ + ...(await vi.importActual>("@/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).visibilityState; + if (ORIGINAL_VISIBILITY) { + Object.defineProperty(Document.prototype, "visibilityState", ORIGINAL_VISIBILITY); + } +} + +function renderFeed(): RenderResult { + return renderWithQueryClient( + +
+ , + { + 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. + // Watching for a React warning proves nothing here: React 18 removed the + // set-state-after-unmount warning, so the assertion has to be on the work + // itself. setQueryData is the poll's first write after the await. + let release: (v: Entry[]) => void = () => {}; + fetchSpy.mockImplementationOnce( + () => new Promise((resolve) => { release = resolve; }) + ); + const writes = vi.spyOn(getQueryClient(), "setQueryData"); + + const { unmount } = renderFeed(); + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000); + }); + unmount(); + writes.mockClear(); + + await act(async () => { + release([{ author: "alice", permlink: "p", stats: {} } as unknown as Entry]); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(writes).not.toHaveBeenCalled(); + writes.mockRestore(); + }); + + it("does not refetch on return when the last poll is still fresh", async () => { + // Deliberate: the catch-up asks for fresh data, it does not force a request. + // A reader alt-tabbing every few seconds would otherwise pull a 257 KB page + // per switch, which is more traffic than this change removes. + renderFeed(); + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + await act(async () => { + setVisibility("visible"); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("does refetch on return once the last poll has gone stale", async () => { + renderFeed(); + await act(async () => { + await vi.advanceTimersByTimeAsync(31_000); + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + setVisibility("hidden"); + await act(async () => { + await vi.advanceTimersByTimeAsync(5 * 60_000); + }); + await act(async () => { + setVisibility("visible"); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(fetchSpy.mock.calls.length).toBeGreaterThan(1); + }); +});