From de7fd3090b6c49549a5d712b2ca3f8c2215bab67 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 23 Aug 2026 06:28:38 +0000 Subject: [PATCH 1/4] perf(waves): stop sending an observer on anonymous waves requests Logged out, both waves views fell back to Ecency's moderation account as the observer. That fallback used to be what filtered the anonymous feed, but esync now applies the moderation mute list to every waves query on its own, so the parameter no longer changes a single row: the same 50 rows come back in the same order with and without it. What it does change is the cache tier. esync treats any request carrying an observer as personalised and skips its 60s shared response cache, on the grounds that a per-viewer key space is unbounded. Anonymous traffic is the opposite of that -- one key every visitor could share -- so every anonymous visitor was paying for a cache entry no one else could reuse. Uncached that request is ~8ms; served from the cache it is ~1.3ms. Pass undefined instead, so anonymous waves and shorts requests land on the shared entry. --- .../app/waves/_components/waves-list-view.tsx | 15 +++++++++------ .../app/waves/_components/waves-reels-view.tsx | 6 ++++-- apps/web/src/consts/observer.ts | 16 +++++++++------- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/web/src/app/waves/_components/waves-list-view.tsx b/apps/web/src/app/waves/_components/waves-list-view.tsx index 09dd026914..aabe536d42 100644 --- a/apps/web/src/app/waves/_components/waves-list-view.tsx +++ b/apps/web/src/app/waves/_components/waves-list-view.tsx @@ -20,7 +20,6 @@ import { import { useWavesTagFilter } from "@/app/waves/_context"; import { useBottomPagination } from "@/core/hooks"; import { Button } from "@ui/button"; -import { DEFAULT_OBSERVER } from "@/consts/observer"; import i18next from "i18next"; import { sentry } from "@/core/sentry/lazy-sentry"; @@ -40,11 +39,15 @@ export function WavesListView({ feedType, username }: Props) { // server-side and each page still arrives full (client-side filtering would // shrink pages). Note esync actually drops muted authors here, unlike the // Hive bridge, which only flags them `stats.gray`. - // Ecency's own moderation mutes are not this parameter's job: esync applies - // that list to every waves request on top of the observer's, so it holds for - // signed-in viewers too. The fallback below only keeps anonymous requests - // shaped like the rest. - const observer = username || DEFAULT_OBSERVER; + // + // Logged out this stays undefined rather than falling back to Ecency's + // moderation account. That fallback used to be what filtered the anonymous + // feed; esync now applies the moderation mute list to every waves request on + // its own, so sending it changed nothing except the cache tier: any observer + // marks a request personalised, and personalised requests skip the 60s shared + // response cache. Every anonymous visitor was paying for a per-viewer cache + // entry that no other viewer could ever reuse. + const observer = username; const queryOptions = useMemo(() => { if (selectedSource) { return getWavesFeedQueryOptions({ containers: [selectedSource], observer }); diff --git a/apps/web/src/app/waves/_components/waves-reels-view.tsx b/apps/web/src/app/waves/_components/waves-reels-view.tsx index 9afe7e3845..b4f87e29d9 100644 --- a/apps/web/src/app/waves/_components/waves-reels-view.tsx +++ b/apps/web/src/app/waves/_components/waves-reels-view.tsx @@ -8,7 +8,6 @@ import { Button } from "@ui/button"; import { useInfiniteDataFlow } from "@/utils"; import { WavesReelItem } from "@/app/waves/_components/waves-reel-item"; import { WavesFastReplyDialog } from "@/app/waves/_components/waves-fast-reply-dialog"; -import { DEFAULT_OBSERVER } from "@/consts/observer"; interface Props { username?: string; @@ -20,7 +19,10 @@ interface Props { * Selected via the "Shorts" source tab; renders in place of the wave card list. */ export function WavesReelsView({ username }: Props) { - const observer = username || DEFAULT_OBSERVER; + // Undefined when logged out, like the waves list: esync applies the + // moderation mute list to the shorts feed itself, and an observer would only + // push the request off the shared 60s response cache (see waves-list-view). + const observer = username; const queryOptions = useMemo(() => getShortsFeedQueryOptions({ observer }), [observer]); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, isError, refetch } = diff --git a/apps/web/src/consts/observer.ts b/apps/web/src/consts/observer.ts index 7a252c85e1..f4563cd72f 100644 --- a/apps/web/src/consts/observer.ts +++ b/apps/web/src/consts/observer.ts @@ -18,13 +18,15 @@ * swapping to the logged-in user client-side would filter every page except * the one people actually look at. Feeds personalise instead at the server, * where cache-policy already marks those tiers user-specific. - * 3. This is NOT how Ecency's moderation mutes reach the waves feed. An - * observer only ever carries the mute list of whoever is viewing, so before - * esync applied the moderation list itself, signed-in users (who send their - * own name) got none of it. esync now ANDs `@ecency`'s on-chain mutes into - * every waves query on top of the observer's, so muting an account there is - * what takes it out of the feed for everyone. Bridge reads still behave as - * described above. + * 3. This is NOT how Ecency's moderation mutes reach the waves feed, and the + * waves feed no longer sends this default at all. An observer only ever + * carries the mute list of whoever is viewing, so before esync applied the + * moderation list itself, signed-in users (who send their own name) got none + * of it. esync now ANDs `@ecency`'s on-chain mutes into every waves query on + * top of the observer's, so muting an account there is what takes it out of + * the feed for everyone, and the waves views pass `undefined` when logged + * out so the request stays on the shared response cache. Bridge reads still + * behave as described above. * 4. Kept as a plain literal on purpose. This mirrors `CONFIG.defaultObserver` * in @ecency/sdk, but reading it from the SDK would make every module that * touches an observer depend on the SDK being mocked in specs, and several From 0ae9000b84fb768f141a70ffc102b62bf2ebe4da Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 23 Aug 2026 08:52:55 +0000 Subject: [PATCH 2/4] review: cover what the waves views send as observer Qodo is right that this change had no test behind it, and this is the kind of change that needs one: the response is byte-identical with and without the parameter, so a fallback creeping back in breaks nothing visible -- it only moves every anonymous request off the shared cache, silently. Five cases across both views: anonymous omits the observer, a logged-in viewer still sends their own name (or their personal mutes stop being applied), and neither view ever substitutes the moderation account for a missing viewer. Checked the tests fail for the right reason: restoring `username || DEFAULT_OBSERVER` turns the two anonymous cases red and leaves the logged-in ones green. --- .../specs/app/waves/waves-observer.spec.tsx | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 apps/web/src/specs/app/waves/waves-observer.spec.tsx diff --git a/apps/web/src/specs/app/waves/waves-observer.spec.tsx b/apps/web/src/specs/app/waves/waves-observer.spec.tsx new file mode 100644 index 0000000000..b80237ee4c --- /dev/null +++ b/apps/web/src/specs/app/waves/waves-observer.spec.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +/** + * What the waves views send as `observer`. + * + * The parameter carries the *viewer's* mute list, and esync treats any request + * that has one as personalised, skipping its shared response cache. Logged out + * there is no viewer, so it must be absent: a fallback there costs every + * anonymous visitor a cache entry nobody else can reuse, and filters nothing + * that esync does not already filter server-side. + * + * Logged in it must still be the viewer's own name, or their personal mutes + * stop being applied at all. + */ + +const getWavesFeedQueryOptions = vi.hoisted(() => vi.fn()); +const getShortsFeedQueryOptions = vi.hoisted(() => vi.fn()); + +const stubInfiniteQuery = (queryKey: unknown[]) => ({ + queryKey, + initialPageParam: undefined, + queryFn: async () => [], + getNextPageParam: () => undefined, + enabled: false +}); + +vi.mock("@/utils", async () => ({ + ...(await vi.importActual("@/utils")), + random: vi.fn(), + getAccessToken: vi.fn(() => "mock-token") +})); + +vi.mock("@ecency/sdk", async () => ({ + ...(await vi.importActual>("@ecency/sdk")), + getWavesFeedQueryOptions: getWavesFeedQueryOptions.mockImplementation(() => + stubInfiniteQuery(["waves-feed"]) + ), + getShortsFeedQueryOptions: getShortsFeedQueryOptions.mockImplementation(() => + stubInfiniteQuery(["shorts-feed"]) + ), + getPromotedPostsQuery: vi.fn(() => ({ + queryKey: ["promoted"], + queryFn: async () => [], + enabled: false + })) +})); + +vi.mock("@/app/waves/_context", () => ({ + useWavesTagFilter: () => ({ selectedTag: null, selectedSource: null }) +})); +vi.mock("@/app/waves/_hooks", () => ({ + useWavesAutoRefresh: () => ({ newWaves: [], clear: vi.fn(), now: 0 }) +})); +vi.mock("@/app/waves/_components", () => ({ WavesRefreshPopup: () => null })); +vi.mock("@/app/waves/_components/waves-list-item", () => ({ WavesListItem: () => null })); +vi.mock("@/app/waves/_components/waves-list-loader", () => ({ WavesListLoader: () => null })); +vi.mock("@/app/waves/_components/waves-reel-item", () => ({ WavesReelItem: () => null })); +vi.mock("@/app/waves/_components/waves-fast-reply-dialog", () => ({ + WavesFastReplyDialog: () => null +})); +vi.mock("@/features/shared", () => ({ DetectBottom: () => null })); +vi.mock("@/core/hooks", () => ({ useBottomPagination: () => [vi.fn(), vi.fn()] })); + +import { WavesListView } from "@/app/waves/_components/waves-list-view"; +import { WavesReelsView } from "@/app/waves/_components/waves-reels-view"; + +function renderView(node: React.ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({node}); +} + +describe("waves observer", () => { + beforeEach(() => { + getWavesFeedQueryOptions.mockClear(); + getShortsFeedQueryOptions.mockClear(); + }); + + it("omits the observer on the anonymous waves feed", () => { + renderView(); + + expect(getWavesFeedQueryOptions).toHaveBeenCalled(); + for (const [params] of getWavesFeedQueryOptions.mock.calls) { + expect(params.observer).toBeUndefined(); + } + }); + + it("sends the logged-in viewer as the observer on the waves feed", () => { + renderView(); + + expect(getWavesFeedQueryOptions).toHaveBeenCalled(); + for (const [params] of getWavesFeedQueryOptions.mock.calls) { + expect(params.observer).toBe("viewer"); + } + }); + + it("omits the observer on the anonymous shorts feed", () => { + renderView(); + + expect(getShortsFeedQueryOptions).toHaveBeenCalled(); + for (const [params] of getShortsFeedQueryOptions.mock.calls) { + expect(params.observer).toBeUndefined(); + } + }); + + it("sends the logged-in viewer as the observer on the shorts feed", () => { + renderView(); + + expect(getShortsFeedQueryOptions).toHaveBeenCalled(); + for (const [params] of getShortsFeedQueryOptions.mock.calls) { + expect(params.observer).toBe("viewer"); + } + }); + + it("never substitutes Ecency's moderation account for a missing viewer", () => { + // The regression this guards: `username || DEFAULT_OBSERVER` reads as a + // harmless default, and the response is identical either way, so nothing + // visible breaks when it comes back -- only the cache tier changes. + renderView(); + renderView(); + + const observers = [ + ...getWavesFeedQueryOptions.mock.calls, + ...getShortsFeedQueryOptions.mock.calls + ].map(([params]) => params.observer); + + expect(observers.length).toBeGreaterThan(0); + expect(observers).not.toContain("ecency"); + }); +}); From ccff42ed94ef046012817f6d94afdc2acddeabc3 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 23 Aug 2026 09:25:06 +0000 Subject: [PATCH 3/4] docs: correct what an observer does to a bridge comment thread Checked the claim in point 1 instead of preserving it. It says an observer only marks content on the bridge, so swapping it never shortens a feed or a comment thread. The first half holds for ranked feeds; the second is wrong. Same thread, same call, three observers: ecency nodes= 60 depth1= 40 muted authors: none good-karma nodes=303 depth1=281 muted authors: present zzz-no-such-account-xyz nodes= 0 depth1= 0 bridge.get_discussion drops the observer's muted authors outright. That is load-bearing here: it is why the decks thread columns are filtered at all, since they read the bridge directly and pass no observer, so they resolve to CONFIG.defaultObserver and inherit Ecency's mutes. Reading the old comment, they look like a hole in the moderation filter and are not one. Also worth recording: an observer that is not a real account returns an empty thread rather than an unfiltered one. --- apps/web/src/consts/observer.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/consts/observer.ts b/apps/web/src/consts/observer.ts index f4563cd72f..ba43674305 100644 --- a/apps/web/src/consts/observer.ts +++ b/apps/web/src/consts/observer.ts @@ -9,9 +9,15 @@ * * Four things worth knowing before using this: * - * 1. On the Hive bridge an observer only *marks* content. Muted authors' posts - * are still returned, so swapping the observer never shortens a feed or a - * comment thread. The waves feed (esync) is the exception: it drops them. + * 1. On the Hive bridge an observer marks ranked-feed content rather than + * removing it: muted authors' posts come back flagged `stats.gray`, so + * swapping the observer does not shorten a feed. Comment threads are NOT + * like that, despite what this comment used to claim. `bridge.get_discussion` + * drops the observer's muted authors outright — the same thread measured + * 2026-08-23 returns 303 nodes under a neutral observer and 60 under this + * one. (An observer that is not a real account returns nothing at all, so + * never pass an unvalidated name here.) The waves feed (esync) also drops + * them, and applies Ecency's moderation list besides — see 3. * 2. Only personalise the observer where the whole list is fetched under it. * Profile and community routes server-render their first page as this * default and their infinite lists then discard their own first page, so From dd95eb02ee20eeb071c325e23a6fcaf931762486 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sun, 23 Aug 2026 10:22:29 +0000 Subject: [PATCH 4/4] review: build the spec's mocked query keys from QueryKeys CodeRabbit flagged the literal arrays, and it is more than style here. The real key carries the observer, so anonymous and logged-in renders are separate cache entries in production; a literal collapsed them onto one, which is the opposite of what this PR is about and would have quietly misled any later test that seeds or asserts cache state. Added a case pinning that: the two renders must not share a key, and the logged-in one must carry the viewer. It fails if the mocks drift back to a literal, so the alignment stays load-bearing rather than decorative. --- .../specs/app/waves/waves-observer.spec.tsx | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/web/src/specs/app/waves/waves-observer.spec.tsx b/apps/web/src/specs/app/waves/waves-observer.spec.tsx index b80237ee4c..47ac9da2c9 100644 --- a/apps/web/src/specs/app/waves/waves-observer.spec.tsx +++ b/apps/web/src/specs/app/waves/waves-observer.spec.tsx @@ -3,6 +3,7 @@ import { render } from "@testing-library/react"; import "@testing-library/jest-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryKeys } from "@ecency/sdk"; /** * What the waves views send as `observer`. @@ -20,7 +21,15 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const getWavesFeedQueryOptions = vi.hoisted(() => vi.fn()); const getShortsFeedQueryOptions = vi.hoisted(() => vi.fn()); -const stubInfiniteQuery = (queryKey: unknown[]) => ({ +/** + * Keys come from `QueryKeys` rather than a literal, so a mocked feed lands on + * the same cache entry production would give it. That matters here: the key + * carries the observer, so anonymous and logged-in renders are distinct + * entries, exactly as they are in the app. A literal would collapse them onto + * one and quietly hide a cache-sharing bug from any later test that seeds or + * asserts on cache state. + */ +const stubInfiniteQuery = (queryKey: readonly unknown[]) => ({ queryKey, initialPageParam: undefined, queryFn: async () => [], @@ -36,14 +45,16 @@ vi.mock("@/utils", async () => ({ vi.mock("@ecency/sdk", async () => ({ ...(await vi.importActual>("@ecency/sdk")), - getWavesFeedQueryOptions: getWavesFeedQueryOptions.mockImplementation(() => - stubInfiniteQuery(["waves-feed"]) + getWavesFeedQueryOptions: getWavesFeedQueryOptions.mockImplementation( + (params: Parameters[0] = {}) => + stubInfiniteQuery(QueryKeys.posts.wavesFeed(params)) ), - getShortsFeedQueryOptions: getShortsFeedQueryOptions.mockImplementation(() => - stubInfiniteQuery(["shorts-feed"]) + getShortsFeedQueryOptions: getShortsFeedQueryOptions.mockImplementation( + (params: Parameters[0] = {}) => + stubInfiniteQuery(QueryKeys.posts.shortsFeed(params)) ), - getPromotedPostsQuery: vi.fn(() => ({ - queryKey: ["promoted"], + getPromotedPostsQuery: vi.fn((type: string = "feed") => ({ + queryKey: QueryKeys.posts.promoted(type), queryFn: async () => [], enabled: false })) @@ -115,6 +126,22 @@ describe("waves observer", () => { } }); + it("puts the anonymous and logged-in feeds on different cache keys", () => { + // The whole point of dropping the observer is the cache tier, so the key + // has to actually differ. Also guards the mocks above: if they went back to + // a literal key, the two renders would share one entry and every + // cache-sensitive test written after this would be testing a fiction. + renderView(); + const anonymous = getWavesFeedQueryOptions.mock.results[0].value.queryKey; + + getWavesFeedQueryOptions.mockClear(); + renderView(); + const signedIn = getWavesFeedQueryOptions.mock.results[0].value.queryKey; + + expect(anonymous).not.toEqual(signedIn); + expect(signedIn).toContain("viewer"); + }); + it("never substitutes Ecency's moderation account for a missing viewer", () => { // The regression this guards: `username || DEFAULT_OBSERVER` reads as a // harmless default, and the response is identical either way, so nothing