diff --git a/apps/extensions/tests/auth.browser.mjs b/apps/extensions/tests/auth.browser.mjs index 1be09414..65caf606 100644 --- a/apps/extensions/tests/auth.browser.mjs +++ b/apps/extensions/tests/auth.browser.mjs @@ -34,6 +34,7 @@ import { signupPromptArticle, } from "../../web/tests/browser/signup-prompts.mjs"; import { checkTopicFollow } from "../../../scripts/testing/topic-follow.mjs"; +import { checkSourceFollow } from "../../web/tests/browser/source-follow.mjs"; import { onboardingTopics, onboardingSources, @@ -104,6 +105,8 @@ test( let rejectTopics = true; let rejectTopicFollow = true; let savedTopicIds = ["typescript"]; + let savedSourceIds = []; + let rejectSourceFollow = true; const errors = []; const analytics = []; const handle = async (route) => { @@ -194,6 +197,19 @@ test( : savedTopicIds.filter((id) => id !== "typescript"); return send({ followed: payload.followed }); } + if (url.pathname.startsWith("/api/v1/user/preferences/sources/")) { + const payload = route.request().postDataJSON(); + assert.deepEqual(Object.keys(payload), ["followed"]); + if (payload.followed && rejectSourceFollow) { + rejectSourceFollow = false; + return send({}, 503); + } + const id = url.pathname.split("/").at(-1); + savedSourceIds = payload.followed + ? [...new Set([...savedSourceIds, id])] + : savedSourceIds.filter((value) => value !== id); + return send({ followed: payload.followed }); + } if (url.pathname === "/api/v1/user/preferences") { const payload = route.request().postDataJSON(); assert.deepEqual(payload, { topic_ids: ["typescript", "onboarding-0", "onboarding-1"] }); @@ -358,7 +374,7 @@ test( return send(interactionChecks ? notificationFixture() : { items: [], next_cursor: null }); if (endpoint === "preferences") return send({ topic_ids: savedTopicIds }); if (endpoint.endsWith("/preferences")) return send({ preferences: [] }); - if (endpoint === "preferences/sources") return send({ source_ids: [] }); + if (endpoint === "preferences/sources") return send({ source_ids: savedSourceIds }); if (endpoint === "feed") { if (onboarding) { if (onboardingSaved) onboarding = false; @@ -797,6 +813,12 @@ test( `${localBase}#/topics/typescript/news?language=en`, path.resolve(extension, `../${browser}-onboarding`), ); + await checkSourceFollow( + page, + `${localBase}#/sources`, + onboardingSources, + path.resolve(extension, `../${browser}-onboarding`), + ); interactionChecks = true; const interactions = await context.newPage(); diff --git a/apps/web/package.json b/apps/web/package.json index 292d5665..70a8e2d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@dualmark/core": "0.11.0", "@dualmark/nextjs": "0.11.0", "@microsoft/clarity": "1.0.2", + "@tanstack/react-query": "5.104.1", "lucide-react": "^1.49.0", "next": "16.3.8", "radix-ui": "1.6.7", diff --git a/apps/web/src/components/reader-query-provider.tsx b/apps/web/src/components/reader-query-provider.tsx new file mode 100644 index 00000000..9e3fc309 --- /dev/null +++ b/apps/web/src/components/reader-query-provider.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +/** Mounted inside UserProvider's account boundary; private data stays in this tab/session. */ +export function ReaderQueryProvider({ children }: { children: React.ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: Infinity, + gcTime: 0, + retry: false, + networkMode: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: (query) => query.state.status === "error", + }, + // Writes must fail visibly rather than being replayed on a different session. + mutations: { retry: false, networkMode: "always" }, + }, + }), + ); + useEffect(() => () => client.clear(), [client]); + return {children}; +} diff --git a/apps/web/src/components/source-follow.tsx b/apps/web/src/components/source-follow.tsx index 49e6c907..dc283d37 100644 --- a/apps/web/src/components/source-follow.tsx +++ b/apps/web/src/components/source-follow.tsx @@ -1,10 +1,9 @@ "use client"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { AccountError, userRequest } from "@/lib/user"; +import { createContext, useContext } from "react"; +import { useFollowPreferences } from "@/lib/use-follow-preferences"; import { useUser } from "./user-account"; import { FollowButton } from "./follow-button"; -type Preferences = { source_ids: string[] }; const Context = createContext({ ids: [] as string[], loading: true, @@ -22,98 +21,13 @@ const Context = createContext({ }); export const useSourceFollows = () => useContext(Context); export function SourceFollowsProvider({ children }: { children: React.ReactNode }) { - const { user, loading } = useUser(); - const owner = user?.user_id ?? "guest"; - const [state, setState] = useState<{ owner: string; ids: string[]; unavailable: boolean } | null>( - null, - ); - const [busy, setBusy] = useState([]); - const [error, setError] = useState(""); - const [revision, setRevision] = useState(0); - const request = useRef(null); - const mutations = useRef(new Set()); - useEffect(() => { - if (!user) return; - const controller = new AbortController(); - request.current = controller; - userRequest("preferences/sources", { - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), - }) - .then((value) => { - if (!controller.signal.aborted) - setState({ owner, ids: value.source_ids, unavailable: false }); - }) - .catch(() => { - if (!controller.signal.aborted) setState({ owner, ids: [], unavailable: true }); - }); - return () => controller.abort(); - }, [user, owner, revision]); - const current = state?.owner === owner ? state : null; - async function mutate(ids: string[], id?: string) { - if ( - !user || - !current || - current.unavailable || - mutations.current.has(id ?? "all") || - mutations.current.has("all") || - (!id && mutations.current.size) - ) - return false; - const token = id ?? "all"; - const controller = request.current; - if (!controller || controller.signal.aborted) return false; - mutations.current.add(token); - setBusy([...mutations.current]); - setError(""); - try { - const followed = id ? !current.ids.includes(id) : false; - const result = await userRequest( - `preferences/sources${id ? `/${id}` : ""}`, - { - method: "PUT", - headers: { "Content-Type": "application/json", "X-CSRF-Token": user.csrf_token }, - body: JSON.stringify(id ? { followed } : { source_ids: ids }), - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), - }, - ); - if (controller.signal.aborted) return false; - setState((previous) => ({ - owner, - unavailable: false, - ids: - "source_ids" in result - ? result.source_ids - : result.followed - ? [...new Set([...(previous?.owner === owner ? previous.ids : []), id!])] - : (previous?.ids ?? []).filter((value) => value !== id), - })); - return true; - } catch (cause) { - if (!controller.signal.aborted) - setError( - cause instanceof AccountError && cause.status === 422 - ? "Choose up to 100 approved sources." - : "Couldn’t update your sources. Please try again.", - ); - return false; - } finally { - mutations.current.delete(token); - setBusy([...mutations.current]); - } - } + const follows = useFollowPreferences("source"); return ( setRevision((value) => value + 1), - toggle: async (id) => { - await mutate([], id); - }, - save: (ids) => mutate(ids), + ...follows, + error: Object.values(follows.errors).find(Boolean) ?? "", + save: async (ids) => (await follows.save(ids)) !== null, }} > {children} diff --git a/apps/web/src/components/topic-follows.tsx b/apps/web/src/components/topic-follows.tsx index d625245e..07dfbe96 100644 --- a/apps/web/src/components/topic-follows.tsx +++ b/apps/web/src/components/topic-follows.tsx @@ -1,8 +1,8 @@ "use client"; -import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { AccountError, userRequest, type Preferences } from "@/lib/user"; -import { useUser } from "./user-account"; +import { createContext, useContext } from "react"; +import type { Preferences } from "@/lib/user"; +import { useFollowPreferences } from "@/lib/use-follow-preferences"; const Context = createContext({ ids: [] as string[], @@ -23,163 +23,15 @@ const Context = createContext({ export const useTopicFollows = () => useContext(Context); export function TopicFollowsProvider({ children }: { children: React.ReactNode }) { - const { user, loading, sessionRevision } = useUser(); - const owner = user?.user_id ?? "guest"; - const [state, setState] = useState<{ - owner: string; - ids: string[]; - unavailable: boolean; - signal: AbortSignal; - } | null>(null); - const [feedback, setFeedback] = useState({ - owner, - signal: null as AbortSignal | null, - busy: [] as string[], - errors: {} as Record, - messages: {} as Record, - }); - const [revision, setRevision] = useState(0); - const request = useRef<{ owner: string; controller: AbortController; failed: boolean } | null>( - null, - ); - const mutations = useRef(new Map()); - useEffect(() => { - if (loading || owner === "guest") return; - const controller = new AbortController(); - const active = { owner, controller, failed: false }; - request.current = active; - userRequest("preferences", { - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), - }) - .then((value) => { - if (!controller.signal.aborted) - setState({ owner, ids: value.topic_ids, unavailable: false, signal: controller.signal }); - }) - .catch(() => { - if (!controller.signal.aborted) { - active.failed = true; - setState({ owner, ids: [], unavailable: true, signal: controller.signal }); - } - }); - return () => controller.abort(); - }, [owner, loading, revision, sessionRevision]); - useEffect(() => { - const resume = () => { - if (document.visibilityState === "visible" && request.current?.failed) { - request.current.failed = false; - setRevision((value) => value + 1); - } - }; - window.addEventListener("focus", resume); - document.addEventListener("visibilitychange", resume); - return () => { - window.removeEventListener("focus", resume); - document.removeEventListener("visibilitychange", resume); - }; - }, []); - const current = state?.owner === owner && !state.signal.aborted ? state : null; - const currentFeedback = feedback.owner === owner && !feedback.signal?.aborted ? feedback : null; - async function mutate(ids: string[], id?: string): Promise { - const active = request.current; - const token = id ?? "all"; - const pending = [...mutations.current.entries()].filter( - ([, controller]) => !controller.signal.aborted, - ); - if ( - !user || - !current || - current.unavailable || - !active || - active.owner !== owner || - active.controller.signal.aborted || - pending.some(([key]) => key === token || key === "all") || - (!id && pending.length) - ) - return null; - const controller = active.controller; - mutations.current.set(token, controller); - setFeedback((previous) => ({ - owner, - signal: controller.signal, - busy: [...pending.map(([key]) => key), token], - errors: { ...(previous.owner === owner ? previous.errors : {}), [token]: "" }, - messages: { ...(previous.owner === owner ? previous.messages : {}), [token]: "" }, - })); - try { - const result = await userRequest( - id ? `preferences/topics/${id}` : "preferences", - { - method: "PUT", - headers: { "Content-Type": "application/json", "X-CSRF-Token": user.csrf_token }, - body: JSON.stringify(id ? { followed: !current.ids.includes(id) } : { topic_ids: ids }), - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]), - }, - ); - if (controller.signal.aborted) return null; - setState((previous) => ({ - owner, - signal: controller.signal, - unavailable: false, - ids: - "topic_ids" in result - ? result.topic_ids - : result.followed - ? [...new Set([...(previous?.owner === owner ? previous.ids : []), id!])] - : (previous?.owner === owner ? previous.ids : []).filter((value) => value !== id), - })); - if ("followed" in result) - setFeedback((previous) => ({ - ...previous, - messages: { - ...previous.messages, - [token]: result.followed ? "Topic followed." : "Topic unfollowed.", - }, - })); - return "topic_ids" in result - ? result - : { - topic_ids: result.followed - ? [...new Set([...current.ids, id!])] - : current.ids.filter((value) => value !== id), - }; - } catch (error) { - if (!controller.signal.aborted) - setFeedback((previous) => ({ - ...previous, - errors: { - ...previous.errors, - [token]: - error instanceof AccountError && error.status === 422 - ? "You can follow up to 100 active topics. Manage your topics to make room." - : "Couldn’t update this topic. Please try again.", - }, - })); - return null; - } finally { - if (mutations.current.get(token) === controller) mutations.current.delete(token); - if (!controller.signal.aborted) - setFeedback((previous) => ({ - ...previous, - busy: [...mutations.current.entries()] - .filter(([, value]) => !value.signal.aborted) - .map(([key]) => key), - })); - } - } + const follows = useFollowPreferences("topic"); return ( setRevision((value) => value + 1), - toggle: async (id) => { - await mutate([], id); + ...follows, + save: async (ids) => { + const result = await follows.save(ids); + return result ? { topic_ids: result } : null; }, - save: (ids) => mutate(ids), }} > {children} diff --git a/apps/web/src/components/user-account.tsx b/apps/web/src/components/user-account.tsx index be919c38..3a7d5d96 100644 --- a/apps/web/src/components/user-account.tsx +++ b/apps/web/src/components/user-account.tsx @@ -7,6 +7,7 @@ import { Fragment, createContext, useContext, useEffect, useState } from "react" import { UserMenu } from "./user-menu"; import { AccountError, userRequest, type UserIdentity, type UserProfile } from "@/lib/user"; import { profileLinkFromUrl } from "@/lib/profile-links"; +import { ReaderQueryProvider } from "./reader-query-provider"; type Session = { sessionRevision: number; @@ -169,7 +170,9 @@ export function UserProvider({ refreshProfile: () => setProfileVersion((value) => value + 1), }} > - {children} + + {children} + ); } diff --git a/apps/web/src/lib/use-follow-preferences.ts b/apps/web/src/lib/use-follow-preferences.ts new file mode 100644 index 00000000..82e918e6 --- /dev/null +++ b/apps/web/src/lib/use-follow-preferences.ts @@ -0,0 +1,205 @@ +"use client"; + +import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useUser } from "@/components/user-account"; +import { AccountError, userRequest } from "./user"; + +const collections = { + topic: { + path: "preferences", + itemPath: "preferences/topics", + field: "topic_ids", + limitError: "You can follow up to 100 active topics. Manage your topics to make room.", + error: "Couldn’t update this topic. Please try again.", + followed: "Topic followed.", + unfollowed: "Topic unfollowed.", + }, + source: { + path: "preferences/sources", + itemPath: "preferences/sources", + field: "source_ids", + limitError: "Choose up to 100 approved sources.", + error: "Couldn’t update your sources. Please try again.", + followed: "Source followed.", + unfollowed: "Source unfollowed.", + }, +} as const; +type Collection = keyof typeof collections; +type Key = readonly ["reader", string | undefined, number, "follows", Collection]; +type Write = { + key: Key; + id?: string; + ids: string[]; + followed: boolean; + csrf: string; + signal: AbortSignal; +}; +type Result = { ids: string[] } | { followed: boolean }; +type Payload = { topic_ids?: string[]; source_ids?: string[]; followed?: boolean }; + +/** The same account-safe query and write lifecycle serves source and topic follows. */ +export function useFollowPreferences(kind: Collection) { + const { user, loading, sessionRevision } = useUser(); + const client = useQueryClient(); + const config = collections[kind]; + const userId = user?.user_id; + const key: Key = useMemo( + () => ["reader", userId, sessionRevision, "follows", kind], + [userId, sessionRevision, kind], + ); + const mutationKey = [...key, "write"]; + const scope = useRef<{ key: Key; controller: AbortController } | null>(null); + const [feedback, setFeedback] = useState<{ + key: Key; + errors: Record; + messages: Record; + }>({ key, errors: {}, messages: {} }); + useEffect(() => { + const controller = new AbortController(); + scope.current = { key, controller }; + return () => controller.abort(); + }, [key]); + const query = useQuery({ + queryKey: key, + enabled: !loading && !!userId, + queryFn: async ({ signal }) => { + const result = await userRequest(config.path, { + signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]), + }); + const ids = result[config.field]; + if (!Array.isArray(ids)) throw new Error("Invalid follow preferences"); + return ids; + }, + }); + const busy = useMutationState({ + filters: { mutationKey, exact: true, status: "pending" }, + select: (mutation) => (mutation.state.variables as Write).id ?? "all", + }); + const pending = () => + client + .getMutationCache() + .findAll({ mutationKey, exact: true, status: "pending" }) + .map((mutation) => (mutation.state.variables as Write).id ?? "all"); + const mutation = useMutation({ + mutationKey, + onMutate: async (write: Write) => { + // A read started before this write must not replace the acknowledged result. + await client.cancelQueries({ queryKey: write.key, exact: true }); + }, + mutationFn: async (write: Write): Promise => { + const result = await userRequest( + write.id ? `${config.itemPath}/${write.id}` : config.path, + { + method: "PUT", + headers: { "Content-Type": "application/json", "X-CSRF-Token": write.csrf }, + body: JSON.stringify( + write.id ? { followed: write.followed } : { [config.field]: write.ids }, + ), + signal: AbortSignal.any([write.signal, AbortSignal.timeout(15000)]), + }, + ); + if (write.id && typeof result.followed === "boolean") return { followed: result.followed }; + const ids = result[config.field]; + if (!Array.isArray(ids)) throw new Error("Invalid follow preferences"); + return { ids }; + }, + onSuccess: (result, write) => { + if (write.signal.aborted) return; + client.setQueryData(write.key, (previous = []) => + "ids" in result + ? result.ids + : result.followed + ? [...new Set([...previous, write.id!])] + : previous.filter((id) => id !== write.id), + ); + if ("followed" in result) + setFeedback((previous) => ({ + key: write.key, + errors: previous.key === write.key ? previous.errors : {}, + messages: { + ...(previous.key === write.key ? previous.messages : {}), + [write.id!]: result.followed ? config.followed : config.unfollowed, + }, + })); + }, + onError: (error, write) => { + if (write.signal.aborted) return; + setFeedback((previous) => ({ + key: write.key, + messages: previous.key === write.key ? previous.messages : {}, + errors: { + ...(previous.key === write.key ? previous.errors : {}), + [write.id ?? "all"]: + error instanceof AccountError && error.status === 422 + ? config.limitError + : config.error, + }, + })); + }, + }); + const { refetch, isError } = query; + useEffect(() => { + if (!userId || !isError) return; + const resume = () => { + if (document.visibilityState === "visible") void refetch({ cancelRefetch: false }); + }; + window.addEventListener("focus", resume); + document.addEventListener("visibilitychange", resume); + return () => { + window.removeEventListener("focus", resume); + document.removeEventListener("visibilitychange", resume); + }; + }, [userId, isError, refetch]); + async function mutate(ids: string[], id?: string): Promise { + const active = scope.current; + const writes = pending(); + if ( + !user || + loading || + !query.data || + query.isError || + active?.key !== key || + active.controller.signal.aborted || + writes.includes(id ?? "all") || + writes.includes("all") || + (!id && writes.length) + ) + return null; + const token = id ?? "all"; + setFeedback((previous) => ({ + key, + errors: { ...(previous.key === key && kind === "topic" ? previous.errors : {}), [token]: "" }, + messages: { ...(previous.key === key ? previous.messages : {}), [token]: "" }, + })); + const signal = active.controller.signal; + try { + await mutation.mutateAsync({ + key, + ids, + id, + followed: !(client.getQueryData(key) ?? []).includes(id!), + csrf: user.csrf_token, + signal, + }); + return signal.aborted ? null : (client.getQueryData(key) ?? null); + } catch { + return null; + } + } + return { + ids: query.data ?? [], + loading: loading || (!!user && query.isPending), + unavailable: !!user && query.isError, + busy, + errors: feedback.key === key ? feedback.errors : {}, + messages: feedback.key === key ? feedback.messages : {}, + refresh: () => { + if (!loading && userId && !pending().length) void refetch({ cancelRefetch: false }); + }, + toggle: async (id: string) => { + await mutate([], id); + }, + save: (ids: string[]) => mutate(ids), + }; +} diff --git a/apps/web/tests/browser/feed.mjs b/apps/web/tests/browser/feed.mjs index 31f03025..a50ea40d 100644 --- a/apps/web/tests/browser/feed.mjs +++ b/apps/web/tests/browser/feed.mjs @@ -46,6 +46,7 @@ import { chromium } from "playwright"; import { checkFeedOnboarding } from "./feed-onboarding.mjs"; import { checkSignupPrompts, signupPromptArticle } from "./signup-prompts.mjs"; import { checkTopicFollow } from "../../../../scripts/testing/topic-follow.mjs"; +import { checkSourceFollow } from "./source-follow.mjs"; import { onboardingTopics, onboardingSources, @@ -77,6 +78,8 @@ let onboardingSaved = false; let rejectOnboardingPage = true; let rejectTopicFollow = true; let savedTopicIds = [topic.id]; +let savedSourceIds = []; +let rejectSourceFollow = true; const fixture = createServer(async (req, res) => { const requestUrl = new URL(req.url, "http://localhost"); const path = requestUrl.pathname; @@ -253,8 +256,26 @@ const fixture = createServer(async (req, res) => { } else if (path === `/v1/topics/${topic.slug}`) body = topic; else if (path === `/v1/sources/${source.slug}`) body = source; else if (path === "/v1/sources") body = onboardingSources; - else if (path === "/v1/user/preferences/sources") body = { source_ids: [] }; - else if (path === "/v1/user/engagement") + else if (path === "/v1/user/preferences/sources") body = { source_ids: savedSourceIds }; + else if (path.startsWith("/v1/user/preferences/sources/") && req.method === "PUT") { + assert.ok(authenticated); + assert.equal(req.headers["x-csrf-token"], "test"); + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const payload = JSON.parse(Buffer.concat(chunks).toString()); + assert.deepEqual(Object.keys(payload), ["followed"]); + if (payload.followed && rejectSourceFollow) { + rejectSourceFollow = false; + res.writeHead(503, { "Content-Type": "application/json" }); + res.end("{}"); + return; + } + const id = path.split("/").at(-1); + savedSourceIds = payload.followed + ? [...new Set([...savedSourceIds, id])] + : savedSourceIds.filter((value) => value !== id); + body = { followed: payload.followed }; + } else if (path === "/v1/user/engagement") body = [{ article_id: article.id, likes: 0, liked: false, opens: 0 }]; else if (path === `/v1/user/articles/${article.id}/like`) { const chunks = []; @@ -600,6 +621,7 @@ try { `${origin}${topicPath}`, `${output}/web`, ); + await checkSourceFollow(page, `${origin}/sources`, onboardingSources, `${output}/web`); const scrollPage = await context.newPage(); mode = "scroll"; await scrollPage.goto(`${origin}/latest`); diff --git a/apps/web/tests/browser/source-follow.mjs b/apps/web/tests/browser/source-follow.mjs new file mode 100644 index 00000000..56ca23f9 --- /dev/null +++ b/apps/web/tests/browser/source-follow.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; + +export async function checkSourceFollow(page, url, sources, output) { + const reads = []; + const record = (request) => { + if ( + new URL(request.url()).pathname === "/api/v1/user/preferences/sources" && + request.method() === "GET" + ) + reads.push(request.url()); + }; + page.on("request", record); + try { + await page.goto(url); + const cards = sources.map((source) => + page + .locator(".source-card") + .filter({ has: page.getByRole("heading", { name: source.name }) }), + ); + const first = cards[0].getByRole("button", { name: "Follow", exact: true }); + await first.click(); + await cards[0].getByRole("alert").waitFor(); + assert.equal(await first.getAttribute("aria-pressed"), "false"); + await first.click(); + await cards[0].getByRole("button", { name: "Following", exact: true }).waitFor(); + await cards[1].getByRole("button", { name: "Follow", exact: true }).click(); + await cards[1].getByRole("button", { name: "Following", exact: true }).waitFor(); + const initialReads = reads.length; + assert.ok(initialReads <= 1, "Source controls share one preference read"); + await cards[0].getByRole("button", { name: "Following", exact: true }).click(); + await cards[0].getByRole("button", { name: "Follow", exact: true }).waitFor(); + assert.ok(await cards[1].getByRole("button", { name: "Following", exact: true }).isVisible()); + assert.equal(reads.length, initialReads, "Follow changes update the cache without extra reads"); + await page.reload(); + await cards[1].getByRole("button", { name: "Following", exact: true }).waitFor(); + await cards[0].getByRole("button", { name: "Follow", exact: true }).waitFor(); + await mkdir(output, { recursive: true }); + await page.screenshot({ path: `${output}/source-follow.png` }); + } finally { + page.off("request", record); + } +} diff --git a/apps/web/tests/follow-preferences.test.tsx b/apps/web/tests/follow-preferences.test.tsx new file mode 100644 index 00000000..605e565e --- /dev/null +++ b/apps/web/tests/follow-preferences.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment jsdom +import { StrictMode, useEffect } from "react"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { ReaderQueryProvider } from "@/components/reader-query-provider"; +import { useFollowPreferences } from "@/lib/use-follow-preferences"; +import { AccountError, userRequest, type UserIdentity } from "@/lib/user"; + +const account = vi.hoisted(() => ({ + user: null as UserIdentity | null, + loading: false, + sessionRevision: 1, +})); +vi.mock("@/components/user-account", () => ({ useUser: () => account })); +vi.mock("@/lib/user", async (original) => ({ + ...(await original()), + userRequest: vi.fn(), +})); +let client: QueryClient; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function Controls({ kind, copy }: { kind: "source" | "topic"; copy: number }) { + const queryClient = useQueryClient(); + useEffect(() => { + client = queryClient; + }, [queryClient]); + const follows = useFollowPreferences(kind); + return ( +
+ {follows.ids.join(",")} + {Object.values(follows.errors).join("")} + {String(follows.unavailable)} + + + + +
+ ); +} +function App({ kind, copies = 1 }: { kind: "source" | "topic"; copies?: number }) { + return ( + + {Array.from({ length: copies }, (_, copy) => ( + + ))} + + ); +} +beforeEach(() => { + account.user = { + user_id: "reader", + csrf_token: "csrf", + name: null, + email: null, + expires_at: 4102444800, + }; + account.sessionRevision = 1; + vi.mocked(userRequest).mockReset(); +}); +afterEach(cleanup); + +for (const kind of ["topic", "source"] as const) { + const path = kind === "topic" ? "preferences" : "preferences/sources"; + const field = `${kind}_ids`; + const ready = () => + waitFor(() => + expect(screen.getByRole("button", { name: "Follow A 0" })).toHaveProperty("disabled", false), + ); + const puts = () => vi.mocked(userRequest).mock.calls.filter(([, init]) => init?.method === "PUT"); + + it(`${kind}: deduplicates reads and duplicate writes across independent consumers`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: [] }); + render(); + await ready(); + expect(userRequest).toHaveBeenCalledTimes(1); + const write = deferred<{ followed: boolean }>(); + vi.mocked(userRequest).mockImplementationOnce(() => write.promise); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + fireEvent.click(screen.getByRole("button", { name: "Follow A 1" })); + await waitFor(() => expect(puts()).toHaveLength(1)); + await act(async () => write.resolve({ followed: true })); + await waitFor(() => expect(screen.getByTestId("ids-1").textContent).toBe("a")); + expect(screen.getByTestId("ids-0").textContent).toBe("a"); + expect(userRequest).toHaveBeenCalledTimes(2); + }); + + it(`${kind}: merges concurrent item writes and blocks conflicting bulk saves`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: ["outside-page"] }); + render(); + await ready(); + const a = deferred<{ followed: boolean }>(); + const b = deferred<{ followed: boolean }>(); + vi.mocked(userRequest) + .mockImplementationOnce(() => a.promise) + .mockImplementationOnce(() => b.promise); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + fireEvent.click(screen.getByRole("button", { name: "Follow B 0" })); + fireEvent.click(screen.getByRole("button", { name: "Bulk 0" })); + await waitFor(() => expect(puts()).toHaveLength(2)); + await act(async () => b.resolve({ followed: true })); + await act(async () => a.resolve({ followed: true })); + await waitFor(() => expect(screen.getByTestId("ids-0").textContent).toBe("outside-page,b,a")); + expect(puts()).toHaveLength(2); + }); + + it(`${kind}: aborts pending writes on credential refresh and ignores their late result`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: [] }); + const view = render(); + await ready(); + const write = deferred<{ followed: boolean }>(); + vi.mocked(userRequest).mockImplementationOnce(() => write.promise); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + await waitFor(() => expect(puts()).toHaveLength(1)); + const signal = puts()[0][1]!.signal!; + account.user = { ...account.user!, csrf_token: "new-csrf" }; + account.sessionRevision++; + vi.mocked(userRequest).mockResolvedValue({ [field]: ["new-session"] }); + view.rerender(); + expect(signal.aborted).toBe(true); + await waitFor(() => expect(screen.getByTestId("ids-0").textContent).toBe("new-session")); + await act(async () => write.resolve({ followed: true })); + expect(screen.getByTestId("ids-0").textContent).toBe("new-session"); + vi.mocked(userRequest).mockResolvedValue({ followed: true }); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + await waitFor(() => expect(puts()).toHaveLength(2)); + expect(puts()[1][1]!.headers).toEqual({ + "Content-Type": "application/json", + "X-CSRF-Token": "new-csrf", + }); + }); + + it(`${kind}: cancels a late read on sign-out and never fetches guest preferences`, async () => { + const read = deferred>(); + vi.mocked(userRequest).mockImplementationOnce(() => read.promise); + const view = render(); + await waitFor(() => expect(userRequest).toHaveBeenCalledTimes(1)); + const signal = vi.mocked(userRequest).mock.calls[0][1]!.signal!; + account.user = null; + view.rerender(); + expect(signal.aborted).toBe(true); + await act(async () => read.resolve({ [field]: ["private-id"] })); + expect(screen.getByTestId("ids-0").textContent).toBe(""); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + fireEvent.click(screen.getByRole("button", { name: "Refresh 0" })); + expect(userRequest).toHaveBeenCalledTimes(1); + }); + + it(`${kind}: retries a failed read on resume without automatic retry loops`, async () => { + vi.mocked(userRequest).mockRejectedValueOnce(new AccountError(503)); + render(); + await waitFor(() => expect(screen.getByTestId("unavailable-0").textContent).toBe("true")); + expect(userRequest).toHaveBeenCalledTimes(1); + vi.mocked(userRequest).mockResolvedValue({ [field]: ["recovered"] }); + fireEvent.focus(window); + await waitFor(() => expect(screen.getByTestId("ids-0").textContent).toBe("recovered")); + expect(userRequest).toHaveBeenCalledTimes(2); + }); + + it(`${kind}: preserves confirmed IDs and exposes a limit failure without replaying the write`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: ["outside-page"] }); + render(); + await ready(); + vi.mocked(userRequest).mockRejectedValueOnce(new AccountError(422)); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + await waitFor(() => expect(screen.getByTestId("error-0").textContent).toContain("100")); + expect(screen.getByTestId("ids-0").textContent).toBe("outside-page"); + expect(puts()).toHaveLength(1); + expect(puts()[0][0]).toBe(kind === "topic" ? "preferences/topics/a" : `${path}/a`); + expect(puts()[0][1]!.body).toBe('{"followed":true}'); + }); + + it(`${kind}: coalesces repeated resume events after a failed background refresh`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: ["confirmed"] }); + render(); + await ready(); + vi.mocked(userRequest).mockRejectedValueOnce(new AccountError(503)); + fireEvent.click(screen.getByRole("button", { name: "Refresh 0" })); + await waitFor(() => expect(screen.getByTestId("unavailable-0").textContent).toBe("true")); + const read = deferred>(); + vi.mocked(userRequest).mockImplementationOnce(() => read.promise); + fireEvent.focus(window); + fireEvent.focus(window); + fireEvent(document, new Event("visibilitychange")); + expect(userRequest).toHaveBeenCalledTimes(3); + await act(async () => read.resolve({ [field]: ["recovered"] })); + await waitFor(() => expect(screen.getByTestId("ids-0").textContent).toBe("recovered")); + }); + + it(`${kind}: cancels an older refresh before saving so it cannot overwrite the result`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: [] }); + render(); + await ready(); + const read = deferred>(); + vi.mocked(userRequest) + .mockImplementationOnce(() => read.promise) + .mockResolvedValue({ followed: true }); + fireEvent.click(screen.getByRole("button", { name: "Refresh 0" })); + await waitFor(() => expect(userRequest).toHaveBeenCalledTimes(2)); + const signal = vi.mocked(userRequest).mock.calls[1][1]!.signal!; + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + await waitFor(() => expect(screen.getByTestId("ids-0").textContent).toBe("a")); + expect(signal.aborted).toBe(true); + await act(async () => read.resolve({ [field]: ["stale"] })); + expect(screen.getByTestId("ids-0").textContent).toBe("a"); + }); + + it(`${kind}: works in Strict Mode and clears private queries and writes on unmount`, async () => { + vi.mocked(userRequest).mockResolvedValue({ [field]: [] }); + const view = render( + + + , + ); + await ready(); + const write = deferred<{ followed: boolean }>(); + vi.mocked(userRequest).mockImplementationOnce(() => write.promise); + fireEvent.click(screen.getByRole("button", { name: "Follow A 0" })); + await waitFor(() => expect(puts()).toHaveLength(1)); + const signal = puts()[0][1]!.signal!; + const previous = client; + view.unmount(); + expect(signal.aborted).toBe(true); + await act(async () => write.resolve({ followed: true })); + expect(previous.getQueryCache().getAll()).toHaveLength(0); + expect(previous.getMutationCache().getAll()).toHaveLength(0); + }); +} diff --git a/apps/web/tests/topic-follows-provider.test.tsx b/apps/web/tests/topic-follows-provider.test.tsx index 6ca0a7e8..f83f239e 100644 --- a/apps/web/tests/topic-follows-provider.test.tsx +++ b/apps/web/tests/topic-follows-provider.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { TopicFollowsProvider, useTopicFollows } from "@/components/topic-follows"; +import { ReaderQueryProvider } from "@/components/reader-query-provider"; import { TopicFollow } from "@/components/topic-follow"; import { userRequest, type UserIdentity } from "@/lib/user"; @@ -36,12 +37,14 @@ function Controls() { } function App({ count = 2 }: { count?: number }) { return ( - - - {Array.from({ length: count }, (_, i) => ( - - ))} - + + + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); } beforeEach(() => { @@ -70,7 +73,7 @@ it("loads preferences once for 60 buttons and synchronizes duplicate topic butto fireEvent.click(buttons[0]); expect(buttons[1]).toHaveProperty("disabled", true); fireEvent.click(buttons[1]); - expect(userRequest).toHaveBeenCalledTimes(2); + await waitFor(() => expect(userRequest).toHaveBeenCalledTimes(2)); await act(async () => pending.resolve({ followed: true })); expect(screen.getAllByRole("button", { name: "Following" })).toHaveLength(2); view.rerender(); @@ -107,6 +110,7 @@ it("aborts an old account write and ignores its late result", async () => { const pending = deferred<{ followed: boolean }>(); vi.mocked(userRequest).mockImplementationOnce(() => pending.promise); fireEvent.click(screen.getAllByRole("button", { name: "Follow" })[0]); + await waitFor(() => expect(userRequest).toHaveBeenCalledTimes(2)); const signal = vi.mocked(userRequest).mock.calls[1][1]?.signal; account.user = user("second"); view.rerender(); diff --git a/package-lock.json b/package-lock.json index 03fcbea5..c52aac95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -117,6 +117,7 @@ "@dualmark/core": "0.11.0", "@dualmark/nextjs": "0.11.0", "@microsoft/clarity": "1.0.2", + "@tanstack/react-query": "5.104.1", "lucide-react": "^1.49.0", "next": "16.3.8", "radix-ui": "1.6.7", @@ -6166,6 +6167,32 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-store": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz",