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