From 32b89283952c023ac82db900ec2144503f0a7d2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 21:59:55 +0000 Subject: [PATCH 01/31] Add TanStack DB collections (Phase 0 - no behavioral changes) Install @tanstack/db, @tanstack/react-db, and @tanstack/query-db-collection. Define four collections (subscriptions, tags, entries, counts) with types inferred from tRPC router outputs. Create a CollectionsProvider context and initialize collections in TRPCProvider using a vanilla tRPC client for the queryFn bridge. No existing components read from collections yet. Co-Authored-By: Claude Opus 4.6 --- package.json | 3 + pnpm-lock.yaml | 84 ++++++++++++++++++++++++++++ src/lib/collections/context.tsx | 32 +++++++++++ src/lib/collections/counts.ts | 32 +++++++++++ src/lib/collections/entries.ts | 43 ++++++++++++++ src/lib/collections/index.ts | 72 ++++++++++++++++++++++++ src/lib/collections/subscriptions.ts | 39 +++++++++++++ src/lib/collections/tags.ts | 38 +++++++++++++ src/lib/collections/types.ts | 62 ++++++++++++++++++++ src/lib/trpc/provider.tsx | 69 ++++++++++++++++++----- 10 files changed, 459 insertions(+), 15 deletions(-) create mode 100644 src/lib/collections/context.tsx create mode 100644 src/lib/collections/counts.ts create mode 100644 src/lib/collections/entries.ts create mode 100644 src/lib/collections/index.ts create mode 100644 src/lib/collections/subscriptions.ts create mode 100644 src/lib/collections/tags.ts create mode 100644 src/lib/collections/types.ts diff --git a/package.json b/package.json index 886d3ca6..345ed996 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ "@modelcontextprotocol/sdk": "^1.25.2", "@mozilla/readability": "^0.6.0", "@sentry/nextjs": "^10.34.0", + "@tanstack/db": "^0.5.25", + "@tanstack/query-db-collection": "^1.0.22", + "@tanstack/react-db": "^0.1.69", "@tanstack/react-query": "^5.90.19", "@trpc/client": "^11.8.1", "@trpc/react-query": "^11.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ecc6fb4..1de5c558 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@sentry/nextjs': specifier: ^10.34.0 version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.1.1(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.104.1(esbuild@0.27.2)) + '@tanstack/db': + specifier: ^0.5.25 + version: 0.5.25(typescript@5.9.3) + '@tanstack/query-db-collection': + specifier: ^1.0.22 + version: 1.0.22(@tanstack/query-core@5.90.19)(typescript@5.9.3) + '@tanstack/react-db': + specifier: ^0.1.69 + version: 0.1.69(react@19.2.3)(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.90.19 version: 5.90.19(react@19.2.3) @@ -2775,9 +2784,34 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/db-ivm@0.1.17': + resolution: {integrity: sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/db@0.5.25': + resolution: {integrity: sha512-VqVchs6Mm4rw2GyiOkaoD+PJw6lCJT8EI/TzPu8KWZy3QxyOlilpMvEuDTCl0LZdp1iLYlQT1NdgDg0gimV3kQ==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/pacer-lite@0.2.1': + resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} + engines: {node: '>=18'} + '@tanstack/query-core@5.90.19': resolution: {integrity: sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA==} + '@tanstack/query-db-collection@1.0.22': + resolution: {integrity: sha512-feYfOIA/xgf3S/aWIhq7Oov/RE66M0wMOZUk1+oAHZ3W7x0br7JzRKFYwdTtIMtopFt8tDq3Pt2gtZVZu+S7rA==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + + '@tanstack/react-db@0.1.69': + resolution: {integrity: sha512-rqhajRK5InIEKT9RABE9zNbYZL5NGkySjGNVANyilu/ADFHV8rhtkMEnhHcbrzv0grIKpcSlx1AvTgJNbbzjkw==} + peerDependencies: + react: '>=16.8.0' + '@tanstack/react-query@5.90.19': resolution: {integrity: sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ==} peerDependencies: @@ -4230,6 +4264,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -5722,6 +5760,9 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sorted-btree@1.8.1: + resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} @@ -6130,6 +6171,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -9396,8 +9442,38 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.18 + '@tanstack/db-ivm@0.1.17(typescript@5.9.3)': + dependencies: + fractional-indexing: 3.2.0 + sorted-btree: 1.8.1 + typescript: 5.9.3 + + '@tanstack/db@0.5.25(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.17(typescript@5.9.3) + '@tanstack/pacer-lite': 0.2.1 + typescript: 5.9.3 + + '@tanstack/pacer-lite@0.2.1': {} + '@tanstack/query-core@5.90.19': {} + '@tanstack/query-db-collection@1.0.22(@tanstack/query-core@5.90.19)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.25(typescript@5.9.3) + '@tanstack/query-core': 5.90.19 + typescript: 5.9.3 + + '@tanstack/react-db@0.1.69(react@19.2.3)(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.5.25(typescript@5.9.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + transitivePeerDependencies: + - typescript + '@tanstack/react-query@5.90.19(react@19.2.3)': dependencies: '@tanstack/query-core': 5.90.19 @@ -11074,6 +11150,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fresh@2.0.0: {} fs-extra@9.1.0: @@ -12669,6 +12747,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + sorted-btree@1.8.1: {} + source-list-map@2.0.1: {} source-map-js@1.2.1: {} @@ -13100,6 +13180,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.3): + dependencies: + react: 19.2.3 + util-deprecate@1.0.2: {} uuid@9.0.1: {} diff --git a/src/lib/collections/context.tsx b/src/lib/collections/context.tsx new file mode 100644 index 00000000..a967e1ba --- /dev/null +++ b/src/lib/collections/context.tsx @@ -0,0 +1,32 @@ +/** + * Collections React Context + * + * Provides TanStack DB collections to React components via context. + * Collections are created once in the TRPCProvider and shared across + * the component tree. + * + * Usage: + * const { subscriptions, tags, entries, counts } = useCollections(); + */ + +"use client"; + +import { createContext, useContext } from "react"; +import type { Collections } from "./index"; + +const CollectionsContext = createContext(null); + +/** + * Hook to access TanStack DB collections from any component. + * + * @throws Error if used outside of CollectionsProvider + */ +export function useCollections(): Collections { + const collections = useContext(CollectionsContext); + if (!collections) { + throw new Error("useCollections must be used within a CollectionsProvider"); + } + return collections; +} + +export const CollectionsProvider = CollectionsContext.Provider; diff --git a/src/lib/collections/counts.ts b/src/lib/collections/counts.ts new file mode 100644 index 00000000..7d58be8a --- /dev/null +++ b/src/lib/collections/counts.ts @@ -0,0 +1,32 @@ +/** + * Counts Collection + * + * Local-only collection for entry counts (total/unread). + * Updated from server responses after mutations and SSE events. + * Not synced to any backend - purely derived client-side state. + */ + +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import type { CountRecord } from "./types"; + +/** + * Creates the counts collection as a local-only store. + * + * Count records are keyed by string identifiers: + * - "all" - All entries count + * - "starred" - Starred entries count + * - "saved" - Saved articles count + * + * These are populated from server responses (entries.count, mutation results) + * and updated optimistically during mutations. + */ +export function createCountsCollection() { + return createCollection( + localOnlyCollectionOptions({ + id: "counts", + getKey: (item: CountRecord) => item.id, + }) + ); +} + +export type CountsCollection = ReturnType; diff --git a/src/lib/collections/entries.ts b/src/lib/collections/entries.ts new file mode 100644 index 00000000..44990e0c --- /dev/null +++ b/src/lib/collections/entries.ts @@ -0,0 +1,43 @@ +/** + * Entries Collection + * + * On-demand synced collection of feed entries. + * Large dataset, paginated. Fetches only what live queries request. + * Entries fetched for one view (e.g., "All") are reused in other views + * (e.g., "Subscription X") since they're stored by ID in the collection. + */ + +import { createCollection } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; +import type { EntryListItem } from "./types"; + +/** + * Creates the entries collection backed by TanStack Query. + * + * Uses on-demand sync mode so only entries requested by live queries are fetched. + * The queryFn receives filter/sort metadata via loadSubsetOptions, which we'll + * map to our cursor-based tRPC API in later phases. + * + * For Phase 0, this collection is created but not yet consumed by components. + * The queryFn fetches entries via the provided callback. + * + * @param queryClient - The shared QueryClient instance + * @param fetchEntries - Function to fetch entries from the API + */ +export function createEntriesCollection( + queryClient: QueryClient, + fetchEntries: () => Promise +) { + return createCollection( + queryCollectionOptions({ + id: "entries", + queryKey: ["entries", "collection"] as const, + queryFn: fetchEntries, + queryClient, + getKey: (item: EntryListItem) => item.id, + }) + ); +} + +export type EntriesCollection = ReturnType; diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts new file mode 100644 index 00000000..f57b85be --- /dev/null +++ b/src/lib/collections/index.ts @@ -0,0 +1,72 @@ +/** + * TanStack DB Collections + * + * Central module for creating and accessing all collections. + * Collections are created once per browser session (tied to the QueryClient lifecycle). + * + * Architecture: + * Server ──(tRPC)──> TanStack Query ──> TanStack DB Collections + * │ + * SSE events ──────────────────────> Collection writes + * │ + * Live Queries + * (differential dataflow) + * │ + * ▼ + * Components + * (useLiveSuspenseQuery) + */ + +import type { QueryClient } from "@tanstack/react-query"; +import { createSubscriptionsCollection, type SubscriptionsCollection } from "./subscriptions"; +import { createTagsCollection, type TagsCollection } from "./tags"; +import { createEntriesCollection, type EntriesCollection } from "./entries"; +import { createCountsCollection, type CountsCollection } from "./counts"; +import type { Subscription, EntryListItem, TagItem } from "./types"; + +export type { Subscription, EntryListItem, TagItem, CountRecord } from "./types"; +export type { SubscriptionsCollection } from "./subscriptions"; +export type { TagsCollection } from "./tags"; +export type { EntriesCollection } from "./entries"; +export type { CountsCollection } from "./counts"; + +/** + * All collections grouped together for convenient access. + */ +export interface Collections { + subscriptions: SubscriptionsCollection; + tags: TagsCollection; + entries: EntriesCollection; + counts: CountsCollection; +} + +/** + * Fetch functions for populating query-backed collections. + * These are provided by the TRPCProvider which has access to the tRPC client. + */ +export interface CollectionFetchers { + fetchSubscriptions: () => Promise; + fetchTags: () => Promise; + fetchEntries: () => Promise; +} + +/** + * Creates all TanStack DB collections. + * + * Called once in the TRPCProvider when the QueryClient is available. + * The fetcher functions bridge tRPC with the collection queryFn interface. + * + * @param queryClient - The shared QueryClient instance + * @param fetchers - Functions to fetch data from the tRPC API + */ +export function createCollections( + queryClient: QueryClient, + fetchers: CollectionFetchers +): Collections { + return { + subscriptions: createSubscriptionsCollection(queryClient, fetchers.fetchSubscriptions), + tags: createTagsCollection(queryClient, fetchers.fetchTags), + entries: createEntriesCollection(queryClient, fetchers.fetchEntries), + counts: createCountsCollection(), + }; +} diff --git a/src/lib/collections/subscriptions.ts b/src/lib/collections/subscriptions.ts new file mode 100644 index 00000000..aab511c4 --- /dev/null +++ b/src/lib/collections/subscriptions.ts @@ -0,0 +1,39 @@ +/** + * Subscriptions Collection + * + * Eager-synced collection of user subscriptions. + * Small dataset (<1000 items), needed everywhere (sidebar, entry views). + * Loads all subscriptions upfront via a single query. + */ + +import { createCollection } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; +import type { Subscription } from "./types"; + +/** + * Creates the subscriptions collection backed by TanStack Query. + * + * Uses `select` to extract the `items` array from the paginated response. + * The queryKey matches what tRPC uses for `subscriptions.list` with no params, + * so the collection automatically picks up data prefetched by SSR. + * + * @param queryClient - The shared QueryClient instance + * @param fetchSubscriptions - Function to fetch all subscriptions from the API + */ +export function createSubscriptionsCollection( + queryClient: QueryClient, + fetchSubscriptions: () => Promise +) { + return createCollection( + queryCollectionOptions({ + id: "subscriptions", + queryKey: ["subscriptions", "listAll"] as const, + queryFn: fetchSubscriptions, + queryClient, + getKey: (item: Subscription) => item.id, + }) + ); +} + +export type SubscriptionsCollection = ReturnType; diff --git a/src/lib/collections/tags.ts b/src/lib/collections/tags.ts new file mode 100644 index 00000000..96465c6b --- /dev/null +++ b/src/lib/collections/tags.ts @@ -0,0 +1,38 @@ +/** + * Tags Collection + * + * Eager-synced collection of user tags. + * Small dataset (<100 items), needed everywhere (sidebar, filtering). + * Loads all tags upfront via a single query. + */ + +import { createCollection } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; +import type { TagItem } from "./types"; + +/** + * Creates the tags collection backed by TanStack Query. + * + * Uses `select` to extract the `items` array from the tags.list response. + * The uncategorized counts are stored separately in the counts collection. + * + * @param queryClient - The shared QueryClient instance + * @param fetchTags - Function to fetch all tags from the API + */ +export function createTagsCollection( + queryClient: QueryClient, + fetchTags: () => Promise +) { + return createCollection( + queryCollectionOptions({ + id: "tags", + queryKey: ["tags", "listAll"] as const, + queryFn: fetchTags, + queryClient, + getKey: (item: TagItem) => item.id, + }) + ); +} + +export type TagsCollection = ReturnType; diff --git a/src/lib/collections/types.ts b/src/lib/collections/types.ts new file mode 100644 index 00000000..87d5c03b --- /dev/null +++ b/src/lib/collections/types.ts @@ -0,0 +1,62 @@ +/** + * Collection Types + * + * TypeScript types for TanStack DB collections, inferred from tRPC router outputs. + * These types represent the normalized data stored in each collection. + */ + +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "@/server/trpc/root"; + +type RouterOutputs = inferRouterOutputs; + +// --------------------------------------------------------------------------- +// Subscriptions +// --------------------------------------------------------------------------- + +/** + * A single subscription as returned by subscriptions.list or subscriptions.get. + * This is the primary user-facing identifier for a feed. + */ +export type Subscription = RouterOutputs["subscriptions"]["list"]["items"][number]; + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +/** + * An entry list item as returned by entries.list. + * Does not include full content (contentOriginal/contentCleaned). + */ +export type EntryListItem = RouterOutputs["entries"]["list"]["items"][number]; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +/** + * A tag as returned by tags.list. + */ +export type TagItem = RouterOutputs["tags"]["list"]["items"][number]; + +/** + * Uncategorized subscription counts from tags.list. + */ +export type UncategorizedCounts = RouterOutputs["tags"]["list"]["uncategorized"]; + +// --------------------------------------------------------------------------- +// Counts +// --------------------------------------------------------------------------- + +/** + * Entry counts (total + unread) for a specific filter combination. + * Stored in the local-only counts collection keyed by a string identifier. + */ +export interface CountRecord { + /** Unique key for this count (e.g., "all", "starred", "saved") */ + id: string; + /** Total entries matching this filter */ + total: number; + /** Unread entries matching this filter */ + unread: number; +} diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 658e2f14..62fe8e5c 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -2,6 +2,7 @@ * tRPC Provider * * Wraps the application with React Query and tRPC providers. + * Also initializes TanStack DB collections for normalized client-side state. * This must be used at the root of the app for tRPC hooks to work. */ @@ -9,10 +10,13 @@ import { useState, useEffect, type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, TRPCClientError } from "@trpc/client"; +import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client"; import superjson from "superjson"; import { trpc } from "./client"; import { getQueryClient } from "./query-client"; +import type { AppRouter } from "@/server/trpc/root"; +import { createCollections, type Collections } from "@/lib/collections"; +import { CollectionsProvider } from "@/lib/collections/context"; /** * Check if an error is a tRPC UNAUTHORIZED error indicating invalid session. @@ -81,9 +85,28 @@ interface TRPCProviderProps { children: ReactNode; } +/** + * Creates the shared httpBatchLink configuration. + * Used by both the React tRPC client and the vanilla tRPC client. + */ +function createBatchLink() { + return httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + transformer: superjson, + // Include credentials for cookie-based auth + fetch(url, options) { + return fetch(url, { + ...options, + credentials: "include", + }); + }, + }); +} + /** * TRPC Provider component. * Wrap your app with this to enable tRPC hooks. + * Also initializes TanStack DB collections for normalized client-side state. * * @example * ```tsx @@ -131,27 +154,43 @@ export function TRPCProvider({ children }: TRPCProviderProps) { }; }, [queryClient]); + // Create the React tRPC client (for hooks) const [trpcClient] = useState(() => trpc.createClient({ - links: [ - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - transformer: superjson, - // Include credentials for cookie-based auth - fetch(url, options) { - return fetch(url, { - ...options, - credentials: "include", - }); - }, - }), - ], + links: [createBatchLink()], }) ); + // Create a vanilla tRPC client (for collection queryFn calls) + // and initialize TanStack DB collections + const [collections] = useState(() => { + const vanillaClient = createTRPCClient({ + links: [createBatchLink()], + }); + + return createCollections(queryClient, { + fetchSubscriptions: async () => { + // Fetch all subscriptions (unpaginated) for the eager collection + const result = await vanillaClient.subscriptions.list.query({}); + return result.items; + }, + fetchTags: async () => { + const result = await vanillaClient.tags.list.query(); + return result.items; + }, + fetchEntries: async () => { + // Phase 0: return empty array. In Phase 2, this will use on-demand + // sync with loadSubsetOptions to fetch paginated entries. + return []; + }, + }); + }); + return ( - {children} + + {children} + ); } From 5d46766e153927ba8d7a57fdfe6f723e9cf889e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 23:01:26 +0000 Subject: [PATCH 02/31] Migrate sidebar to TanStack DB live queries (Phase 1) TagList reads from TanStack DB tags collection via useLiveSuspenseQuery for reactive updates. Uncategorized counts are stored in the counts collection (populated by the tags.list fetch). TagSubscriptionList uses tRPC useInfiniteQuery with IntersectionObserver for on-demand pagination (standard infinite scroll). Loaded pages are written into a local-only subscriptions collection for fast lookups and optimistic unread count updates. Dual-write strategy: mutations and SSE events update both the old React Query cache and new TanStack DB collections during the transition period. New file: src/lib/collections/writes.ts - collection write utilities called alongside existing cache operations. Co-Authored-By: Claude Opus 4.6 --- src/components/layout/Sidebar.tsx | 13 +- src/components/layout/TagList.tsx | 35 +- src/components/layout/TagSubscriptionList.tsx | 20 +- .../pages/BrokenFeedsSettingsContent.tsx | 4 +- src/components/subscribe/SubscribeContent.tsx | 4 +- src/lib/cache/count-cache.ts | 10 +- src/lib/cache/event-handlers.ts | 26 +- src/lib/cache/operations.ts | 101 +++++- src/lib/collections/index.ts | 25 +- src/lib/collections/subscriptions.ts | 38 +-- src/lib/collections/tags.ts | 39 ++- src/lib/collections/writes.ts | 300 ++++++++++++++++++ src/lib/hooks/useEntryMutations.ts | 6 +- src/lib/hooks/useRealtimeUpdates.ts | 14 +- src/lib/trpc/provider.tsx | 10 +- 15 files changed, 557 insertions(+), 88 deletions(-) create mode 100644 src/lib/collections/writes.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index fd90635e..b0985ffe 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSubscriptionDeleted } from "@/lib/cache/operations"; import { UnsubscribeDialog } from "@/components/feeds/UnsubscribeDialog"; import { EditSubscriptionDialog } from "@/components/feeds/EditSubscriptionDialog"; @@ -27,6 +28,7 @@ interface SidebarProps { export function Sidebar({ onClose }: SidebarProps) { const queryClient = useQueryClient(); + const collections = useCollections(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; title: string; @@ -44,15 +46,17 @@ export function Sidebar({ onClose }: SidebarProps) { onMutate: async (variables) => { // Close dialog immediately for responsive feel setUnsubscribeTarget(null); - // Use centralized cache operation for optimistic removal - handleSubscriptionDeleted(utils, variables.id, queryClient); + // Use centralized cache operation for optimistic removal (dual-write to collections) + handleSubscriptionDeleted(utils, variables.id, queryClient, collections); }, onError: () => { toast.error("Failed to unsubscribe from feed"); - // On error, invalidate to refetch correct state + // On error, invalidate to refetch correct state. + // Subscription infinite queries will re-populate the subscriptions collection. utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); utils.entries.count.invalidate(); + collections.tags.utils.refetch(); }, }); @@ -120,8 +124,11 @@ export function Sidebar({ onClose }: SidebarProps) { currentTagIds={editTarget?.tagIds ?? []} onClose={() => { setEditTarget(null); + // Invalidate subscription queries to refetch with new data. + // Subscription infinite queries will re-populate the subscriptions collection. utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); + collections.tags.utils.refetch(); }} /> diff --git a/src/components/layout/TagList.tsx b/src/components/layout/TagList.tsx index 6a2584f9..cdf2e490 100644 --- a/src/components/layout/TagList.tsx +++ b/src/components/layout/TagList.tsx @@ -2,15 +2,17 @@ * TagList Component * * Renders the list of tags with unread counts in the sidebar. - * Uses useSuspenseQuery so it can stream with Suspense boundaries. + * Uses TanStack DB live queries for reactive updates from the tags collection. + * Uncategorized counts come from the counts collection (populated by the tags fetch). */ "use client"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { usePathname } from "next/navigation"; +import { useLiveSuspenseQuery, useLiveQuery } from "@tanstack/react-db"; import { ClientLink } from "@/components/ui/client-link"; -import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { useExpandedTags } from "@/lib/hooks/useExpandedTags"; import { NavLinkWithIcon } from "@/components/ui/nav-link"; import { ChevronDownIcon, ChevronRightIcon } from "@/components/ui/icon-button"; @@ -30,22 +32,31 @@ interface TagListProps { } /** - * Inner component that suspends on tags.list query. + * Inner component that suspends on tags collection. */ function TagListContent({ onNavigate, onEdit, onUnsubscribe }: TagListProps) { const pathname = usePathname(); - const [tagsData] = trpc.tags.list.useSuspenseQuery(); + const { tags: tagsCollection, counts: countsCollection } = useCollections(); + const { data: tags } = useLiveSuspenseQuery(tagsCollection); + const { data: allCounts } = useLiveQuery(countsCollection); const { isExpanded, toggleExpanded } = useExpandedTags(); - const tags = tagsData.items; - const uncategorized = tagsData.uncategorized; + // Get uncategorized counts from the counts collection + const uncategorized = useMemo(() => { + const record = allCounts.find((c) => c.id === "uncategorized"); + return { + feedCount: record?.total ?? 0, + unreadCount: record?.unread ?? 0, + }; + }, [allCounts]); // Tags sorted alphabetically, showing only tags that have subscriptions - const sortedTags = [...(tags ?? [])] - .filter((tag) => tag.feedCount > 0) - .sort((a, b) => a.name.localeCompare(b.name)); + const sortedTags = useMemo( + () => [...tags].filter((tag) => tag.feedCount > 0).sort((a, b) => a.name.localeCompare(b.name)), + [tags] + ); - const hasUncategorized = (uncategorized?.feedCount ?? 0) > 0; + const hasUncategorized = uncategorized.feedCount > 0; const hasTags = sortedTags.length > 0 || hasUncategorized; const isActiveLink = (href: string) => { @@ -145,7 +156,7 @@ function TagListContent({ onNavigate, onEdit, onUnsubscribe }: TagListProps) { isActive={isActiveLink("/uncategorized")} icon={} label="Uncategorized" - count={uncategorized?.unreadCount ?? 0} + count={uncategorized.unreadCount} onClick={onNavigate} /> diff --git a/src/components/layout/TagSubscriptionList.tsx b/src/components/layout/TagSubscriptionList.tsx index c668d355..25eb726f 100644 --- a/src/components/layout/TagSubscriptionList.tsx +++ b/src/components/layout/TagSubscriptionList.tsx @@ -4,12 +4,17 @@ * Renders subscriptions within a tag section using infinite scrolling. * Subscriptions are fetched per-tag (or uncategorized) when the section is expanded, * with more pages loaded automatically as the user scrolls. + * + * Loaded subscriptions are also written into the TanStack DB subscriptions collection + * for fast lookups and optimistic updates elsewhere in the app. */ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { upsertSubscriptionsInCollection } from "@/lib/collections/writes"; import { SubscriptionItem } from "./SubscriptionItem"; interface TagSubscriptionListProps { @@ -41,6 +46,7 @@ export function TagSubscriptionList({ onUnsubscribe, }: TagSubscriptionListProps) { const sentinelRef = useRef(null); + const collections = useCollections(); const subscriptionsQuery = trpc.subscriptions.list.useInfiniteQuery( { tagId, uncategorized, limit: 50 }, @@ -49,10 +55,20 @@ export function TagSubscriptionList({ } ); - const allSubscriptions = subscriptionsQuery.data?.pages.flatMap((p) => p.items) ?? []; + const allSubscriptions = useMemo( + () => subscriptionsQuery.data?.pages.flatMap((p) => p.items) ?? [], + [subscriptionsQuery.data] + ); const { hasNextPage, isFetchingNextPage, fetchNextPage } = subscriptionsQuery; + // Write loaded subscriptions into the TanStack DB collection for fast lookups + useEffect(() => { + if (allSubscriptions.length > 0) { + upsertSubscriptionsInCollection(collections, allSubscriptions); + } + }, [collections, allSubscriptions]); + // Infinite scroll: observe sentinel element to load more useEffect(() => { if (!hasNextPage || isFetchingNextPage) return; diff --git a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx index e4bfd659..d758cf8e 100644 --- a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx +++ b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx @@ -11,6 +11,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSubscriptionDeleted } from "@/lib/cache/operations"; import { getFeedDisplayName, formatRelativeTime, formatFutureTime } from "@/lib/format"; import { Button } from "@/components/ui/button"; @@ -39,6 +40,7 @@ interface BrokenFeed { export default function BrokenFeedsSettingsContent() { const queryClient = useQueryClient(); + const collections = useCollections(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; title: string; @@ -50,7 +52,7 @@ export default function BrokenFeedsSettingsContent() { const unsubscribeMutation = trpc.subscriptions.delete.useMutation({ onMutate: (variables) => { // Use centralized cache operation for optimistic removal - handleSubscriptionDeleted(utils, variables.id, queryClient); + handleSubscriptionDeleted(utils, variables.id, queryClient, collections); }, onSuccess: () => { utils.brokenFeeds.list.invalidate(); diff --git a/src/components/subscribe/SubscribeContent.tsx b/src/components/subscribe/SubscribeContent.tsx index 2b2b3823..eb14cfbd 100644 --- a/src/components/subscribe/SubscribeContent.tsx +++ b/src/components/subscribe/SubscribeContent.tsx @@ -12,6 +12,7 @@ import { useState, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSubscriptionCreated } from "@/lib/cache/operations"; import { clientPush } from "@/lib/navigation"; import { Button } from "@/components/ui/button"; @@ -37,6 +38,7 @@ type Step = "input" | "discovery" | "preview"; export function SubscribeContent() { const queryClient = useQueryClient(); + const collections = useCollections(); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState(); const [step, setStep] = useState("input"); @@ -68,7 +70,7 @@ export function SubscribeContent() { const subscribeMutation = trpc.subscriptions.create.useMutation({ onSuccess: (data) => { // Use centralized cache operation for consistent behavior with SSE events - handleSubscriptionCreated(utils, data, queryClient); + handleSubscriptionCreated(utils, data, queryClient, collections); clientPush("/all"); }, onError: () => { diff --git a/src/lib/cache/count-cache.ts b/src/lib/cache/count-cache.ts index 9330c813..4cdd4f2b 100644 --- a/src/lib/cache/count-cache.ts +++ b/src/lib/cache/count-cache.ts @@ -7,6 +7,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; +import type { Collections } from "@/lib/collections"; /** * Full subscription type as returned by subscriptions.list/get. @@ -137,8 +138,15 @@ function forEachCachedSubscription( export function findCachedSubscription( utils: TRPCClientUtils, queryClient: QueryClient, - subscriptionId: string + subscriptionId: string, + collections?: Collections | null ): CachedSubscription | undefined { + // Check TanStack DB collection first (synchronous, has all data) + if (collections) { + const sub = collections.subscriptions.get(subscriptionId); + if (sub) return sub as CachedSubscription; + } + // Check the unparameterized query first (cheaper lookup) const listData = utils.subscriptions.list.getData(); if (listData) { diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 36ddb94f..b63957e5 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -8,9 +8,15 @@ import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; +import type { Collections } from "@/lib/collections"; import { handleSubscriptionCreated, handleSubscriptionDeleted, handleNewEntry } from "./operations"; import { updateEntriesInListCache, updateEntryMetadataInCache } from "./entry-cache"; import { applySyncTagChanges, removeSyncTags } from "./count-cache"; +import { + addTagToCollection, + updateTagInCollection, + removeTagFromCollection, +} from "@/lib/collections/writes"; // ============================================================================ // Event Types @@ -176,13 +182,14 @@ export type SyncEvent = export function handleSyncEvent( utils: TRPCClientUtils, queryClient: QueryClient, - event: SyncEvent + event: SyncEvent, + collections?: Collections | null ): void { switch (event.type) { case "new_entry": // Update unread counts without invalidating entries.list if (event.feedType) { - handleNewEntry(utils, event.subscriptionId, event.feedType, queryClient); + handleNewEntry(utils, event.subscriptionId, event.feedType, queryClient, collections); } break; @@ -227,7 +234,8 @@ export function handleSyncEvent( tags: subscription.tags, fetchFullContent: false, }, - queryClient + queryClient, + collections ); break; } @@ -235,26 +243,32 @@ export function handleSyncEvent( case "subscription_deleted": // Check if already removed (optimistic update from same tab) { + // Check both the old cache and the TanStack DB collection const currentData = utils.subscriptions.list.getData(); - const alreadyRemoved = + const alreadyRemovedFromCache = currentData && !currentData.items.some((s) => s.id === event.subscriptionId); + const alreadyRemovedFromCollection = + collections && !collections.subscriptions.has(event.subscriptionId); - if (!alreadyRemoved) { - handleSubscriptionDeleted(utils, event.subscriptionId, queryClient); + if (!alreadyRemovedFromCache || !alreadyRemovedFromCollection) { + handleSubscriptionDeleted(utils, event.subscriptionId, queryClient, collections); } } break; case "tag_created": applySyncTagChanges(utils, [event.tag], []); + addTagToCollection(collections ?? null, event.tag); break; case "tag_updated": applySyncTagChanges(utils, [], [event.tag]); + updateTagInCollection(collections ?? null, event.tag); break; case "tag_deleted": removeSyncTags(utils, [event.tagId]); + removeTagFromCollection(collections ?? null, event.tagId); break; case "import_progress": diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index a93e01f0..7ad78fbc 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -12,6 +12,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; +import type { Collections, Subscription } from "@/lib/collections"; import { updateEntriesReadStatus, updateEntryStarredStatus, @@ -26,6 +27,18 @@ import { removeSubscriptionFromCache, findCachedSubscription, } from "./count-cache"; +import { + adjustSubscriptionUnreadInCollection, + adjustTagUnreadInCollection, + adjustUncategorizedUnreadInCollection, + addSubscriptionToCollection, + removeSubscriptionFromCollection, + setSubscriptionUnreadInCollection, + setTagUnreadInCollection, + setBulkSubscriptionUnreadInCollection, + adjustTagFeedCountInCollection, + adjustUncategorizedFeedCountInCollection, +} from "@/lib/collections/writes"; /** * Entry type (matches feed type schema). @@ -75,7 +88,8 @@ export interface SubscriptionData { function updateSubscriptionAndTagCounts( utils: TRPCClientUtils, subscriptionDeltas: Map, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { adjustSubscriptionUnreadCounts(utils, subscriptionDeltas, queryClient); const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( @@ -84,6 +98,11 @@ function updateSubscriptionAndTagCounts( queryClient ); adjustTagUnreadCounts(utils, tagDeltas, uncategorizedDelta); + + // Also update TanStack DB collections (dual-write during migration) + adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); } /** @@ -217,7 +236,8 @@ export function handleEntriesMarkedRead( utils: TRPCClientUtils, entries: EntryWithContext[], read: boolean, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { if (entries.length === 0) return; @@ -242,7 +262,7 @@ export function handleEntriesMarkedRead( } // 3. Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient); + updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient, collections); // 4. Update All Articles unread count adjustEntriesCount(utils, {}, delta * entries.length); @@ -359,10 +379,15 @@ export function handleEntryScoreChanged( export function handleSubscriptionCreated( utils: TRPCClientUtils, subscription: SubscriptionData, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { addSubscriptionToCache(utils, subscription); + // Also add to TanStack DB collection (dual-write during migration) + // Cast to Subscription type - the SubscriptionData shape matches what the collection expects + addSubscriptionToCollection(collections ?? null, subscription as unknown as Subscription); + // Invalidate only the affected subscription list queries // - The unparameterized query (no input) for entry content pages // - Per-tag queries for tags the subscription belongs to @@ -412,6 +437,18 @@ export function handleSubscriptionCreated( // Directly update entries.count for All Articles adjustEntriesCount(utils, {}, subscription.unreadCount, subscription.unreadCount); + + // Update tag/uncategorized feedCount and unreadCount in TanStack DB collections + if (subscription.tags.length === 0) { + adjustUncategorizedFeedCountInCollection(collections ?? null, 1); + adjustUncategorizedUnreadInCollection(collections ?? null, subscription.unreadCount); + } else { + for (const tag of subscription.tags) { + adjustTagFeedCountInCollection(collections ?? null, tag.id, 1); + const tagDeltas = new Map([[tag.id, subscription.unreadCount]]); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + } + } } /** @@ -431,13 +468,15 @@ export function handleSubscriptionCreated( export function handleSubscriptionDeleted( utils: TRPCClientUtils, subscriptionId: string, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { // Look up subscription data before removing from cache // This lets us do targeted updates instead of broad invalidations - const subscription = queryClient - ? findCachedSubscription(utils, queryClient, subscriptionId) - : undefined; + // Check TanStack DB collection first (has all data, synchronous lookup) + const subscription = + collections?.subscriptions.get(subscriptionId) ?? + (queryClient ? findCachedSubscription(utils, queryClient, subscriptionId) : undefined); // Remove from all subscription caches removeSubscriptionFromCache(utils, subscriptionId); @@ -496,6 +535,21 @@ export function handleSubscriptionDeleted( // Always invalidate entries.list - entries from this subscription should be filtered out utils.entries.list.invalidate(); + + // Remove from TanStack DB collection and adjust tag/uncategorized counts + if (subscription) { + if (subscription.tags.length === 0) { + adjustUncategorizedFeedCountInCollection(collections ?? null, -1); + adjustUncategorizedUnreadInCollection(collections ?? null, -subscription.unreadCount); + } else { + for (const tag of subscription.tags) { + adjustTagFeedCountInCollection(collections ?? null, tag.id, -1); + const tagDeltas = new Map([[tag.id, -subscription.unreadCount]]); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + } + } + } + removeSubscriptionFromCollection(collections ?? null, subscriptionId); } /** @@ -520,7 +574,8 @@ export function handleNewEntry( utils: TRPCClientUtils, subscriptionId: string | null, feedType: "web" | "email" | "saved", - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { // Update subscription and tag unread counts (only for non-saved entries) if (subscriptionId) { @@ -529,7 +584,7 @@ export function handleNewEntry( subscriptionDeltas.set(subscriptionId, 1); // +1 unread // Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient); + updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient, collections); } // Update All Articles unread count (+1 unread, +1 total) @@ -580,7 +635,8 @@ export interface BulkUnreadCounts { export function setCounts( utils: TRPCClientUtils, counts: UnreadCounts, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { // Set global counts utils.entries.count.setData({}, counts.all); @@ -611,6 +667,20 @@ export function setCounts( if (counts.uncategorized) { setUncategorizedUnreadCount(utils, counts.uncategorized.unread); } + + // Also update TanStack DB collections (dual-write during migration) + if (counts.subscription) { + setSubscriptionUnreadInCollection( + collections ?? null, + counts.subscription.id, + counts.subscription.unread + ); + } + if (counts.tags) { + for (const tag of counts.tags) { + setTagUnreadInCollection(collections ?? null, tag.id, tag.unread); + } + } } /** @@ -624,7 +694,8 @@ export function setCounts( export function setBulkCounts( utils: TRPCClientUtils, counts: BulkUnreadCounts, - queryClient?: QueryClient + queryClient?: QueryClient, + collections?: Collections | null ): void { // Set global counts utils.entries.count.setData({}, counts.all); @@ -655,6 +726,12 @@ export function setBulkCounts( if (counts.uncategorized) { setUncategorizedUnreadCount(utils, counts.uncategorized.unread); } + + // Also update TanStack DB collections (dual-write during migration) + setBulkSubscriptionUnreadInCollection(collections ?? null, subscriptionUpdates); + for (const tag of counts.tags) { + setTagUnreadInCollection(collections ?? null, tag.id, tag.unread); + } } /** diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index f57b85be..84c514de 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -22,9 +22,15 @@ import { createSubscriptionsCollection, type SubscriptionsCollection } from "./s import { createTagsCollection, type TagsCollection } from "./tags"; import { createEntriesCollection, type EntriesCollection } from "./entries"; import { createCountsCollection, type CountsCollection } from "./counts"; -import type { Subscription, EntryListItem, TagItem } from "./types"; +import type { EntryListItem, TagItem, UncategorizedCounts } from "./types"; -export type { Subscription, EntryListItem, TagItem, CountRecord } from "./types"; +export type { + Subscription, + EntryListItem, + TagItem, + CountRecord, + UncategorizedCounts, +} from "./types"; export type { SubscriptionsCollection } from "./subscriptions"; export type { TagsCollection } from "./tags"; export type { EntriesCollection } from "./entries"; @@ -45,8 +51,10 @@ export interface Collections { * These are provided by the TRPCProvider which has access to the tRPC client. */ export interface CollectionFetchers { - fetchSubscriptions: () => Promise; - fetchTags: () => Promise; + fetchTagsAndUncategorized: () => Promise<{ + items: TagItem[]; + uncategorized: UncategorizedCounts; + }>; fetchEntries: () => Promise; } @@ -63,10 +71,13 @@ export function createCollections( queryClient: QueryClient, fetchers: CollectionFetchers ): Collections { + // Create counts first since tags needs it for uncategorized counts + const counts = createCountsCollection(); + return { - subscriptions: createSubscriptionsCollection(queryClient, fetchers.fetchSubscriptions), - tags: createTagsCollection(queryClient, fetchers.fetchTags), + subscriptions: createSubscriptionsCollection(), + tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized, counts), entries: createEntriesCollection(queryClient, fetchers.fetchEntries), - counts: createCountsCollection(), + counts, }; } diff --git a/src/lib/collections/subscriptions.ts b/src/lib/collections/subscriptions.ts index aab511c4..f753de28 100644 --- a/src/lib/collections/subscriptions.ts +++ b/src/lib/collections/subscriptions.ts @@ -1,36 +1,32 @@ /** * Subscriptions Collection * - * Eager-synced collection of user subscriptions. - * Small dataset (<1000 items), needed everywhere (sidebar, entry views). - * Loads all subscriptions upfront via a single query. + * Local-only collection of user subscriptions. + * Populated incrementally as sidebar tag sections load pages via useInfiniteQuery, + * and by SSE/sync events (addSubscriptionToCollection, removeSubscriptionFromCollection). + * + * Used for: + * - Fast synchronous lookups by ID (collection.get(id)) + * - Optimistic unread count updates (writeUpdate) + * - findCachedSubscription fallback */ -import { createCollection } from "@tanstack/react-db"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import type { QueryClient } from "@tanstack/react-query"; +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; import type { Subscription } from "./types"; /** - * Creates the subscriptions collection backed by TanStack Query. - * - * Uses `select` to extract the `items` array from the paginated response. - * The queryKey matches what tRPC uses for `subscriptions.list` with no params, - * so the collection automatically picks up data prefetched by SSR. + * Creates the subscriptions collection as a local-only store. * - * @param queryClient - The shared QueryClient instance - * @param fetchSubscriptions - Function to fetch all subscriptions from the API + * Unlike query-backed collections, this doesn't fetch data automatically. + * Data flows in from: + * 1. TagSubscriptionList useInfiniteQuery pages (via writeInsert/writeUpdate) + * 2. SSE subscription_created events (via addSubscriptionToCollection) + * 3. SSE subscription_deleted events (via removeSubscriptionFromCollection) */ -export function createSubscriptionsCollection( - queryClient: QueryClient, - fetchSubscriptions: () => Promise -) { +export function createSubscriptionsCollection() { return createCollection( - queryCollectionOptions({ + localOnlyCollectionOptions({ id: "subscriptions", - queryKey: ["subscriptions", "listAll"] as const, - queryFn: fetchSubscriptions, - queryClient, getKey: (item: Subscription) => item.id, }) ); diff --git a/src/lib/collections/tags.ts b/src/lib/collections/tags.ts index 96465c6b..289a85c0 100644 --- a/src/lib/collections/tags.ts +++ b/src/lib/collections/tags.ts @@ -4,31 +4,60 @@ * Eager-synced collection of user tags. * Small dataset (<100 items), needed everywhere (sidebar, filtering). * Loads all tags upfront via a single query. + * + * The tags.list API also returns uncategorized counts, which are stored + * in the counts collection under the "uncategorized" key. */ import { createCollection } from "@tanstack/react-db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import type { QueryClient } from "@tanstack/react-query"; -import type { TagItem } from "./types"; +import type { CountsCollection } from "./counts"; +import type { TagItem, UncategorizedCounts } from "./types"; /** * Creates the tags collection backed by TanStack Query. * * Uses `select` to extract the `items` array from the tags.list response. - * The uncategorized counts are stored separately in the counts collection. + * Also writes uncategorized counts to the counts collection as a side effect. * * @param queryClient - The shared QueryClient instance - * @param fetchTags - Function to fetch all tags from the API + * @param fetchTagsAndUncategorized - Function to fetch tags + uncategorized counts from the API + * @param countsCollection - The counts collection to write uncategorized counts to */ export function createTagsCollection( queryClient: QueryClient, - fetchTags: () => Promise + fetchTagsAndUncategorized: () => Promise<{ + items: TagItem[]; + uncategorized: UncategorizedCounts; + }>, + countsCollection: CountsCollection ) { return createCollection( queryCollectionOptions({ id: "tags", queryKey: ["tags", "listAll"] as const, - queryFn: fetchTags, + queryFn: async () => { + const result = await fetchTagsAndUncategorized(); + + // Store uncategorized counts in the counts collection + const existing = countsCollection.get("uncategorized"); + if (existing) { + countsCollection.utils.writeUpdate({ + id: "uncategorized", + total: result.uncategorized.feedCount, + unread: result.uncategorized.unreadCount, + }); + } else { + countsCollection.utils.writeInsert({ + id: "uncategorized", + total: result.uncategorized.feedCount, + unread: result.uncategorized.unreadCount, + }); + } + + return result.items; + }, queryClient, getKey: (item: TagItem) => item.id, }) diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts new file mode 100644 index 00000000..1c49191f --- /dev/null +++ b/src/lib/collections/writes.ts @@ -0,0 +1,300 @@ +/** + * Collection Write Utilities + * + * Functions that update TanStack DB collections alongside the existing React Query cache. + * Each function accepts `Collections | null` and no-ops when null, enabling gradual + * migration where callers can optionally pass collections. + * + * Uses `collection.utils.writeUpdate()` for query-backed collections, which writes + * directly to the synced data store without triggering onInsert/onUpdate handlers + * or query refetches. + */ + +import type { Collections } from "./index"; +import type { Subscription, TagItem } from "./types"; + +// ============================================================================ +// Subscription Collection Writes +// ============================================================================ + +/** + * Adjusts subscription unread counts by delta values. + * Used by handleEntriesMarkedRead, handleNewEntry, etc. + */ +export function adjustSubscriptionUnreadInCollection( + collections: Collections | null, + subscriptionDeltas: Map +): void { + if (!collections || subscriptionDeltas.size === 0) return; + + const updates: Array & { id: string }> = []; + for (const [id, delta] of subscriptionDeltas) { + const current = collections.subscriptions.get(id); + if (current) { + updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + } + } + if (updates.length > 0) { + collections.subscriptions.utils.writeUpdate(updates); + } +} + +/** + * Sets the absolute unread count for a single subscription. + * Used by setCounts when server returns authoritative counts. + */ +export function setSubscriptionUnreadInCollection( + collections: Collections | null, + subscriptionId: string, + unread: number +): void { + if (!collections) return; + + const current = collections.subscriptions.get(subscriptionId); + if (current) { + collections.subscriptions.utils.writeUpdate({ id: subscriptionId, unreadCount: unread }); + } +} + +/** + * Sets absolute unread counts for multiple subscriptions. + * Used by setBulkCounts after markRead mutation. + */ +export function setBulkSubscriptionUnreadInCollection( + collections: Collections | null, + updates: Map +): void { + if (!collections || updates.size === 0) return; + + const writeUpdates: Array & { id: string }> = []; + for (const [id, unread] of updates) { + const current = collections.subscriptions.get(id); + if (current) { + writeUpdates.push({ id, unreadCount: unread }); + } + } + if (writeUpdates.length > 0) { + collections.subscriptions.utils.writeUpdate(writeUpdates); + } +} + +/** + * Adds a new subscription to the collection. + * Used by handleSubscriptionCreated (SSE/sync events). + */ +export function addSubscriptionToCollection( + collections: Collections | null, + subscription: Subscription +): void { + if (!collections) return; + + // Check for duplicates (SSE race condition) + if (collections.subscriptions.has(subscription.id)) return; + + collections.subscriptions.utils.writeInsert(subscription); +} + +/** + * Removes a subscription from the collection. + * Used by handleSubscriptionDeleted. + */ +export function removeSubscriptionFromCollection( + collections: Collections | null, + subscriptionId: string +): void { + if (!collections) return; + + if (collections.subscriptions.has(subscriptionId)) { + collections.subscriptions.utils.writeDelete(subscriptionId); + } +} + +/** + * Upserts subscriptions into the collection from infinite query pages. + * Called by TagSubscriptionList as pages load, so the collection accumulates + * subscriptions for fast lookups and optimistic updates. + */ +export function upsertSubscriptionsInCollection( + collections: Collections | null, + subscriptions: Subscription[] +): void { + if (!collections || subscriptions.length === 0) return; + + const inserts: Subscription[] = []; + const updates: Array & { id: string }> = []; + + for (const sub of subscriptions) { + if (collections.subscriptions.has(sub.id)) { + updates.push(sub); + } else { + inserts.push(sub); + } + } + + if (inserts.length > 0) { + collections.subscriptions.utils.writeInsert(inserts); + } + if (updates.length > 0) { + collections.subscriptions.utils.writeUpdate(updates); + } +} + +// ============================================================================ +// Tag Collection Writes +// ============================================================================ + +/** + * Adjusts tag unread counts by delta values. + * Used by handleEntriesMarkedRead, handleNewEntry, etc. + * + * Note: uncategorized counts are stored in the counts collection + * (populated by the tags.list fetch) and updated via adjustUncategorizedUnreadInCollection. + */ +export function adjustTagUnreadInCollection( + collections: Collections | null, + tagDeltas: Map +): void { + if (!collections || tagDeltas.size === 0) return; + + const updates: Array & { id: string }> = []; + for (const [id, delta] of tagDeltas) { + const current = collections.tags.get(id); + if (current) { + updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + } + } + if (updates.length > 0) { + collections.tags.utils.writeUpdate(updates); + } +} + +/** + * Sets the absolute unread count for a single tag. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setTagUnreadInCollection( + collections: Collections | null, + tagId: string, + unread: number +): void { + if (!collections) return; + + const current = collections.tags.get(tagId); + if (current) { + collections.tags.utils.writeUpdate({ id: tagId, unreadCount: unread }); + } +} + +/** + * Adjusts the feedCount for a tag (used when subscriptions are created/deleted). + */ +export function adjustTagFeedCountInCollection( + collections: Collections | null, + tagId: string, + delta: number +): void { + if (!collections) return; + + const current = collections.tags.get(tagId); + if (current) { + collections.tags.utils.writeUpdate({ + id: tagId, + feedCount: Math.max(0, current.feedCount + delta), + }); + } +} + +/** + * Adds a new tag to the collection. + * Used by SSE tag_created events. + */ +export function addTagToCollection( + collections: Collections | null, + tag: { id: string; name: string; color: string | null } +): void { + if (!collections) return; + + if (collections.tags.has(tag.id)) return; + + collections.tags.utils.writeInsert({ + id: tag.id, + name: tag.name, + color: tag.color, + feedCount: 0, + unreadCount: 0, + createdAt: new Date(), + } as TagItem); +} + +/** + * Updates a tag in the collection. + * Used by SSE tag_updated events. + */ +export function updateTagInCollection( + collections: Collections | null, + tag: { id: string; name: string; color: string | null } +): void { + if (!collections) return; + + if (collections.tags.has(tag.id)) { + collections.tags.utils.writeUpdate({ + id: tag.id, + name: tag.name, + color: tag.color, + }); + } +} + +/** + * Removes a tag from the collection. + * Used by SSE tag_deleted events. + */ +export function removeTagFromCollection(collections: Collections | null, tagId: string): void { + if (!collections) return; + + if (collections.tags.has(tagId)) { + collections.tags.utils.writeDelete(tagId); + } +} + +// ============================================================================ +// Uncategorized Count Writes (via Counts Collection) +// ============================================================================ + +/** + * Adjusts the uncategorized unread count by a delta value. + * Stored in the counts collection under the "uncategorized" key. + */ +export function adjustUncategorizedUnreadInCollection( + collections: Collections | null, + delta: number +): void { + if (!collections || delta === 0) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.utils.writeUpdate({ + id: "uncategorized", + unread: Math.max(0, current.unread + delta), + }); + } +} + +/** + * Adjusts the uncategorized feed count by a delta value. + * Used when subscriptions with no tags are created or deleted. + */ +export function adjustUncategorizedFeedCountInCollection( + collections: Collections | null, + delta: number +): void { + if (!collections || delta === 0) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.utils.writeUpdate({ + id: "uncategorized", + total: Math.max(0, current.total + delta), + }); + } +} diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 3217b270..157829c1 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -23,6 +23,7 @@ import { useCallback, useMemo, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleEntryScoreChanged, setCounts, @@ -159,6 +160,7 @@ interface EntryMutationTracking { export function useEntryMutations(): UseEntryMutationsResult { const utils = trpc.useUtils(); const queryClient = useQueryClient(); + const collections = useCollections(); // Track pending mutations per entry for timestamp-based state merging const entryTracking = useRef(new Map()); @@ -331,7 +333,7 @@ export function useEntryMutations(): UseEntryMutationsResult { ); // Update counts (always apply, not dependent on timestamp) - setBulkCounts(utils, data.counts, queryClient); + setBulkCounts(utils, data.counts, queryClient, collections); }, onError: (error, variables) => { @@ -421,7 +423,7 @@ export function useEntryMutations(): UseEntryMutationsResult { updateEntriesInListCache(queryClient, [data.entry.id], { starred: data.entry.starred }); // Update counts (always apply) - setCounts(utils, data.counts, queryClient); + setCounts(utils, data.counts, queryClient, collections); }, onError: (error, variables) => { diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index ad1708cb..705389f6 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -17,6 +17,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSyncEvent, type SyncEvent } from "@/lib/cache/event-handlers"; /** @@ -431,6 +432,7 @@ function parseEventData(data: string): SyncEvent | null { export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpdatesResult { const utils = trpc.useUtils(); const queryClient = useQueryClient(); + const collections = useCollections(); // Connection status state const [connectionStatus, setConnectionStatus] = useState("disconnected"); @@ -532,10 +534,10 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // Update the appropriate cursor based on event type updateCursorForEvent(data); - // Delegate to shared handler for cache updates - handleSyncEvent(utils, queryClient, data); + // Delegate to shared handler for cache updates (dual-write to collections) + handleSyncEvent(utils, queryClient, data, collections); }, - [utils, queryClient, updateCursorForEvent] + [utils, queryClient, updateCursorForEvent, collections] ); /** @@ -556,10 +558,10 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda }, }); - // Process each event through the shared handler (same as SSE path) + // Process each event through the shared handler (same as SSE path, dual-write to collections) for (const event of result.events) { updateCursorForEvent(event); - handleSyncEvent(utils, queryClient, event); + handleSyncEvent(utils, queryClient, event, collections); } // If there are more events, schedule another sync soon @@ -573,7 +575,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda console.error("Sync failed:", error); return null; } - }, [utils, queryClient, updateCursorForEvent]); + }, [utils, queryClient, updateCursorForEvent, collections]); /** * Starts polling mode when SSE is unavailable. diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 62fe8e5c..d38bdad3 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -169,15 +169,7 @@ export function TRPCProvider({ children }: TRPCProviderProps) { }); return createCollections(queryClient, { - fetchSubscriptions: async () => { - // Fetch all subscriptions (unpaginated) for the eager collection - const result = await vanillaClient.subscriptions.list.query({}); - return result.items; - }, - fetchTags: async () => { - const result = await vanillaClient.tags.list.query(); - return result.items; - }, + fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), fetchEntries: async () => { // Phase 0: return empty array. In Phase 2, this will use on-demand // sync with loadSubsetOptions to fetch paginated entries. From d69606c146f4f53bf986f4117cc6a8dc168cb241 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 00:43:27 +0000 Subject: [PATCH 03/31] Add entries collection with dual-write for mutations and SSE (Phase 2a) Establishes the entries collection as a local-only store populated from tRPC infinite query results. Mutations and SSE events now write to both the existing React Query cache and the entries collection. Key changes: - entries collection: local-only, populated from SuspendingEntryList pages - writes.ts: add entry write utilities (updateEntryRead/Starred/Score/Metadata) - useEntryMutations: dual-write to collection on mutate/success/error - event-handlers: dual-write entry_updated and entry_state_changed to collection - EntryContent: sync entries.get data to collection on load - Simplified collection creation (removed unused fetchEntries parameter) This is Phase 2a of the TanStack DB migration - no behavioral changes, just establishing the collection infrastructure that Phase 2b will use to replace the React Query cache manipulation code. Co-Authored-By: Claude Opus 4.6 --- src/components/entries/EntryContent.tsx | 17 +++ .../entries/SuspendingEntryList.tsx | 51 +++++--- src/lib/cache/event-handlers.ts | 31 ++--- src/lib/collections/entries.ts | 46 ++++---- src/lib/collections/index.ts | 5 +- src/lib/collections/writes.ts | 109 +++++++++++++++++- src/lib/hooks/useEntryMutations.ts | 46 ++++++++ src/lib/trpc/provider.tsx | 5 - 8 files changed, 244 insertions(+), 66 deletions(-) diff --git a/src/components/entries/EntryContent.tsx b/src/components/entries/EntryContent.tsx index 00f04582..ceedf93d 100644 --- a/src/components/entries/EntryContent.tsx +++ b/src/components/entries/EntryContent.tsx @@ -15,6 +15,12 @@ import { trpc } from "@/lib/trpc/client"; import { toast } from "sonner"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useShowOriginalPreference } from "@/lib/hooks/useShowOriginalPreference"; +import { useCollections } from "@/lib/collections/context"; +import { + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryScoreInCollection, +} from "@/lib/collections/writes"; import { ScrollContainer } from "@/components/layout/ScrollContainerContext"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { getDomain } from "@/lib/format"; @@ -80,6 +86,17 @@ function EntryContentInner({ // Entry is guaranteed to exist after suspense resolves const entry = data.entry; + // Sync entries.get data to entries collection. + // This ensures the collection has the latest server state for this entry + // (e.g., if it was marked read by another client between list fetch and detail fetch). + const collections = useCollections(); + useEffect(() => { + if (!entry) return; + updateEntryReadInCollection(collections, [entry.id], entry.read); + updateEntryStarredInCollection(collections, entry.id, entry.starred); + updateEntryScoreInCollection(collections, entry.id, entry.score, entry.implicitScore); + }, [collections, entry]); + // Show original preference is stored per-feed in localStorage const [showOriginal, setShowOriginal] = useShowOriginalPreference(entry?.feedId); diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index 92fea2e1..6b3127b2 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -20,6 +20,8 @@ import { useKeyboardShortcuts } from "@/lib/hooks/useKeyboardShortcuts"; import { useUrlViewPreferences } from "@/lib/hooks/useUrlViewPreferences"; import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { useScrollContainer } from "@/components/layout/ScrollContainerContext"; +import { useCollections } from "@/lib/collections/context"; +import { upsertEntriesInCollection } from "@/lib/collections/writes"; import { EntryList, type ExternalQueryState } from "./EntryList"; interface SuspendingEntryListProps { @@ -32,6 +34,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) const { enabled: keyboardShortcutsEnabled } = useKeyboardShortcutsContext(); const utils = trpc.useUtils(); const scrollContainerRef = useScrollContainer(); + const collections = useCollections(); // Get query input from URL - shared with parent's non-suspending query const queryInput = useEntriesListInput(); @@ -45,28 +48,38 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) refetchOnWindowFocus: false, }); + // Flatten all page items for collection population + const allPageItems = useMemo( + () => data?.pages.flatMap((page) => page.items) ?? [], + [data?.pages] + ); + + // Populate entries collection from tRPC pages as they load. + // This makes entries available for O(1) lookup by mutations/SSE. + useEffect(() => { + upsertEntriesInCollection(collections, allPageItems); + }, [collections, allPageItems]); + // Flatten entries from all pages const entries = useMemo( () => - data?.pages.flatMap((page) => - page.items.map((entry) => ({ - id: entry.id, - feedId: entry.feedId, - subscriptionId: entry.subscriptionId, - type: entry.type, - url: entry.url, - title: entry.title, - author: entry.author, - summary: entry.summary, - publishedAt: entry.publishedAt, - fetchedAt: entry.fetchedAt, - read: entry.read, - starred: entry.starred, - feedTitle: entry.feedTitle, - siteName: entry.siteName, - })) - ) ?? [], - [data?.pages] + allPageItems.map((entry) => ({ + id: entry.id, + feedId: entry.feedId, + subscriptionId: entry.subscriptionId, + type: entry.type, + url: entry.url, + title: entry.title, + author: entry.author, + summary: entry.summary, + publishedAt: entry.publishedAt, + fetchedAt: entry.fetchedAt, + read: entry.read, + starred: entry.starred, + feedTitle: entry.feedTitle, + siteName: entry.siteName, + })), + [allPageItems] ); // Compute next/previous entry IDs for keyboard navigation diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index b63957e5..54f9f23a 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -16,6 +16,9 @@ import { addTagToCollection, updateTagInCollection, removeTagFromCollection, + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryMetadataInCollection, } from "@/lib/collections/writes"; // ============================================================================ @@ -193,21 +196,20 @@ export function handleSyncEvent( } break; - case "entry_updated": + case "entry_updated": { // Update entry metadata directly in caches - updateEntryMetadataInCache( - utils, - event.entryId, - { - title: event.metadata.title, - author: event.metadata.author, - summary: event.metadata.summary, - url: event.metadata.url, - publishedAt: event.metadata.publishedAt ? new Date(event.metadata.publishedAt) : null, - }, - queryClient - ); + const metadata = { + title: event.metadata.title, + author: event.metadata.author, + summary: event.metadata.summary, + url: event.metadata.url, + publishedAt: event.metadata.publishedAt ? new Date(event.metadata.publishedAt) : null, + }; + updateEntryMetadataInCache(utils, event.entryId, metadata, queryClient); + // Also update entries collection + updateEntryMetadataInCollection(collections ?? null, event.entryId, metadata); break; + } case "entry_state_changed": // Update read/starred state in cache @@ -215,6 +217,9 @@ export function handleSyncEvent( read: event.read, starred: event.starred, }); + // Also update entries collection + updateEntryReadInCollection(collections ?? null, [event.entryId], event.read); + updateEntryStarredInCollection(collections ?? null, event.entryId, event.starred); break; case "subscription_created": { diff --git a/src/lib/collections/entries.ts b/src/lib/collections/entries.ts index 44990e0c..751187e3 100644 --- a/src/lib/collections/entries.ts +++ b/src/lib/collections/entries.ts @@ -1,40 +1,36 @@ /** * Entries Collection * - * On-demand synced collection of feed entries. - * Large dataset, paginated. Fetches only what live queries request. - * Entries fetched for one view (e.g., "All") are reused in other views - * (e.g., "Subscription X") since they're stored by ID in the collection. + * Local-only collection of feed entries, populated incrementally from tRPC + * infinite query results. Components use tRPC useInfiniteQuery for paginated + * fetching (cursor-based, SSR-prefetchable), then populate the collection + * as pages load. + * + * The collection provides: + * - O(1) entry lookup by ID (collection.get(id)) instead of scanning pages + * - Centralized writes for mutations/SSE (writeUpdate) instead of updating + * every infinite query cache + * - Reactive read via useLiveQuery for components that need live updates + * + * Data flow: + * tRPC useInfiniteQuery → pages load → upsert into collection + * Mutations/SSE → writeUpdate on collection → components re-read merged data */ -import { createCollection } from "@tanstack/react-db"; -import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import type { QueryClient } from "@tanstack/react-query"; +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; import type { EntryListItem } from "./types"; /** - * Creates the entries collection backed by TanStack Query. - * - * Uses on-demand sync mode so only entries requested by live queries are fetched. - * The queryFn receives filter/sort metadata via loadSubsetOptions, which we'll - * map to our cursor-based tRPC API in later phases. - * - * For Phase 0, this collection is created but not yet consumed by components. - * The queryFn fetches entries via the provided callback. + * Creates the entries collection as a local-only store. * - * @param queryClient - The shared QueryClient instance - * @param fetchEntries - Function to fetch entries from the API + * Populated incrementally from tRPC infinite query results (similar to + * how subscriptions collection is populated from TagSubscriptionList). + * Mutations and SSE events write directly to the collection. */ -export function createEntriesCollection( - queryClient: QueryClient, - fetchEntries: () => Promise -) { +export function createEntriesCollection() { return createCollection( - queryCollectionOptions({ + localOnlyCollectionOptions({ id: "entries", - queryKey: ["entries", "collection"] as const, - queryFn: fetchEntries, - queryClient, getKey: (item: EntryListItem) => item.id, }) ); diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index 84c514de..50ef2523 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -22,7 +22,7 @@ import { createSubscriptionsCollection, type SubscriptionsCollection } from "./s import { createTagsCollection, type TagsCollection } from "./tags"; import { createEntriesCollection, type EntriesCollection } from "./entries"; import { createCountsCollection, type CountsCollection } from "./counts"; -import type { EntryListItem, TagItem, UncategorizedCounts } from "./types"; +import type { TagItem, UncategorizedCounts } from "./types"; export type { Subscription, @@ -55,7 +55,6 @@ export interface CollectionFetchers { items: TagItem[]; uncategorized: UncategorizedCounts; }>; - fetchEntries: () => Promise; } /** @@ -77,7 +76,7 @@ export function createCollections( return { subscriptions: createSubscriptionsCollection(), tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized, counts), - entries: createEntriesCollection(queryClient, fetchers.fetchEntries), + entries: createEntriesCollection(), counts, }; } diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index 1c49191f..fe1a0129 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -11,7 +11,7 @@ */ import type { Collections } from "./index"; -import type { Subscription, TagItem } from "./types"; +import type { Subscription, TagItem, EntryListItem } from "./types"; // ============================================================================ // Subscription Collection Writes @@ -298,3 +298,110 @@ export function adjustUncategorizedFeedCountInCollection( }); } } + +// ============================================================================ +// Entry Collection Writes +// ============================================================================ + +/** + * Updates the read status for entries in the collection. + * No-ops for entries not currently in the collection. + * This is O(1) per entry (by key lookup) — replaces the O(n) page scanning + * in the old entry-cache.ts. + */ +export function updateEntryReadInCollection( + collections: Collections | null, + entryIds: string[], + read: boolean +): void { + if (!collections || entryIds.length === 0) return; + + const updates: Array & { id: string }> = []; + for (const id of entryIds) { + if (collections.entries.has(id)) { + updates.push({ id, read }); + } + } + if (updates.length > 0) { + collections.entries.utils.writeUpdate(updates); + } +} + +/** + * Updates the starred status for an entry in the collection. + */ +export function updateEntryStarredInCollection( + collections: Collections | null, + entryId: string, + starred: boolean +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.utils.writeUpdate({ id: entryId, starred }); + } +} + +/** + * Updates the score fields for an entry in the collection. + */ +export function updateEntryScoreInCollection( + collections: Collections | null, + entryId: string, + score: number | null, + implicitScore: number +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.utils.writeUpdate({ id: entryId, score, implicitScore }); + } +} + +/** + * Updates entry metadata (title, author, summary, url, publishedAt) in the collection. + * Used for SSE entry_updated events. + */ +export function updateEntryMetadataInCollection( + collections: Collections | null, + entryId: string, + metadata: Partial +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.utils.writeUpdate({ id: entryId, ...metadata }); + } +} + +/** + * Upserts entries from tRPC infinite query pages into the collection. + * Called as pages load to populate the local-only entries collection. + * + * Uses insert for new entries and update for existing ones (e.g., when + * a refetch returns entries that were already loaded from a previous page). + */ +export function upsertEntriesInCollection( + collections: Collections | null, + entries: EntryListItem[] +): void { + if (!collections || entries.length === 0) return; + + const inserts: EntryListItem[] = []; + const updates: Array & { id: string }> = []; + + for (const entry of entries) { + if (collections.entries.has(entry.id)) { + updates.push(entry); + } else { + inserts.push(entry); + } + } + + if (inserts.length > 0) { + collections.entries.utils.writeInsert(inserts); + } + if (updates.length > 0) { + collections.entries.utils.writeUpdate(updates); + } +} diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 157829c1..b045e19d 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -37,6 +37,11 @@ import { updateEntriesInListCache, updateEntriesInAffectedListCaches, } from "@/lib/cache/entry-cache"; +import { + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryScoreInCollection, +} from "@/lib/collections/writes"; /** * Entry type for routing. @@ -273,6 +278,16 @@ export function useEntryMutations(): UseEntryMutationsResult { winningState.implicitScore, queryClient ); + + // Update entries collection with winning state + updateEntryReadInCollection(collections, [entryId], winningState.read); + updateEntryStarredInCollection(collections, entryId, winningState.starred); + updateEntryScoreInCollection( + collections, + entryId, + winningState.score, + winningState.implicitScore + ); }; // markRead mutation - uses optimistic updates for instant UI feedback @@ -289,6 +304,9 @@ export function useEntryMutations(): UseEntryMutationsResult { variables.read ); + // Optimistic update in entries collection + updateEntryReadInCollection(collections, entryIds, variables.read); + // Start tracking for each entry for (const entryId of entryIds) { const prevEntry = optimisticContext.previousEntries.get(entryId); @@ -332,6 +350,12 @@ export function useEntryMutations(): UseEntryMutationsResult { scope ); + // Update entries in collection with server state + for (const entry of data.entries) { + updateEntryReadInCollection(collections, [entry.id], entry.read); + updateEntryScoreInCollection(collections, entry.id, entry.score, entry.implicitScore); + } + // Update counts (always apply, not dependent on timestamp) setBulkCounts(utils, data.counts, queryClient, collections); }, @@ -351,6 +375,7 @@ export function useEntryMutations(): UseEntryMutationsResult { // All mutations failed, rollback to original state from tracking // (not from context, which may have captured intermediate state) updateEntriesReadStatus(utils, [entryId], result.originalRead, queryClient); + updateEntryReadInCollection(collections, [entryId], result.originalRead); } } } @@ -395,6 +420,9 @@ export function useEntryMutations(): UseEntryMutationsResult { variables.starred ); + // Optimistic update in entries collection + updateEntryStarredInCollection(collections, variables.id, variables.starred); + // Start tracking const originalStarred = optimisticContext.wasStarred; const cachedData = utils.entries.get.getData({ id: variables.id }); @@ -422,6 +450,15 @@ export function useEntryMutations(): UseEntryMutationsResult { // Update list cache with starred status updateEntriesInListCache(queryClient, [data.entry.id], { starred: data.entry.starred }); + // Update entries collection with server state + updateEntryStarredInCollection(collections, data.entry.id, data.entry.starred); + updateEntryScoreInCollection( + collections, + data.entry.id, + data.entry.score, + data.entry.implicitScore + ); + // Update counts (always apply) setCounts(utils, data.counts, queryClient, collections); }, @@ -436,6 +473,7 @@ export function useEntryMutations(): UseEntryMutationsResult { // All mutations failed, rollback to original state from tracking // (not from context, which may have captured intermediate state) updateEntryStarredStatus(utils, variables.id, result.originalStarred, queryClient); + updateEntryStarredInCollection(collections, variables.id, result.originalStarred); } } toast.error(variables.starred ? "Failed to star entry" : "Failed to unstar entry"); @@ -452,6 +490,14 @@ export function useEntryMutations(): UseEntryMutationsResult { data.entry.implicitScore, queryClient ); + + // Update entries collection with score + updateEntryScoreInCollection( + collections, + data.entry.id, + data.entry.score, + data.entry.implicitScore + ); }, onError: () => { toast.error("Failed to update score"); diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index d38bdad3..d12d5299 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -170,11 +170,6 @@ export function TRPCProvider({ children }: TRPCProviderProps) { return createCollections(queryClient, { fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), - fetchEntries: async () => { - // Phase 0: return empty array. In Phase 2, this will use on-demand - // sync with loadSubsetOptions to fetch paginated entries. - return []; - }, }); }); From 9a14af582be1853b0450bf834f1c0920b3aefbb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 00:48:13 +0000 Subject: [PATCH 04/31] Migrate fallback components to TanStack DB collections (Phase 2b) Replace React Query cache scanning with O(1) collection lookups: - EntryContentFallback: use entries collection instead of findEntryInListCache - UnifiedEntriesContent TitleFallback: use subscriptions/tags collections instead of findCachedSubscription and tags.list.getData() Co-Authored-By: Claude Opus 4.6 --- src/components/entries/EntryContentFallback.tsx | 9 ++++----- src/components/entries/UnifiedEntriesContent.tsx | 15 ++++++--------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/components/entries/EntryContentFallback.tsx b/src/components/entries/EntryContentFallback.tsx index fe7f564c..03ac217b 100644 --- a/src/components/entries/EntryContentFallback.tsx +++ b/src/components/entries/EntryContentFallback.tsx @@ -12,8 +12,7 @@ "use client"; import { useCallback } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { findEntryInListCache } from "@/lib/cache/entry-cache"; +import { useCollections } from "@/lib/collections/context"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { ScrollContainer } from "@/components/layout/ScrollContainerContext"; import { Button } from "@/components/ui/button"; @@ -48,10 +47,10 @@ function ButtonShimmer({ width = "w-24" }: { width?: string }) { * Star and Read buttons are functional via optimistic updates. */ export function EntryContentFallback({ entryId, onBack }: EntryContentFallbackProps) { - const queryClient = useQueryClient(); + const collections = useCollections(); - // Try to find cached entry data from list queries - const cachedEntry = findEntryInListCache(queryClient, entryId); + // O(1) lookup from entries collection (populated by SuspendingEntryList) + const cachedEntry = collections.entries.get(entryId) ?? null; // Entry mutations for star/read - these work optimistically even during loading const { markRead, star, unstar, setScore } = useEntryMutations(); diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index 7553da5d..49313f04 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -16,7 +16,6 @@ import { Suspense, useMemo, useEffect, useRef } from "react"; import { usePathname } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; import { EntryPageLayout, TitleSkeleton, TitleText } from "./EntryPageLayout"; import { EntryContent } from "./EntryContent"; import { SuspendingEntryList } from "./SuspendingEntryList"; @@ -28,7 +27,7 @@ import { useUrlViewPreferences } from "@/lib/hooks/useUrlViewPreferences"; import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { type ViewType } from "@/lib/hooks/viewPreferences"; import { trpc } from "@/lib/trpc/client"; -import { findCachedSubscription } from "@/lib/cache/count-cache"; +import { useCollections } from "@/lib/collections/context"; import { type EntryType } from "@/lib/hooks/useEntryMutations"; /** @@ -245,17 +244,16 @@ function EntryListTitle({ routeInfo }: { routeInfo: RouteInfo }) { * Used as the Suspense fallback for the title slot. */ function TitleFallback({ routeInfo }: { routeInfo: RouteInfo }) { - const utils = trpc.useUtils(); - const queryClient = useQueryClient(); + const collections = useCollections(); // Static title - render immediately (shouldn't suspend anyway, but handle it) if (routeInfo.title !== null) { return {routeInfo.title}; } - // Subscription title from cache + // Subscription title from collection (O(1) lookup) if (routeInfo.subscriptionId) { - const subscription = findCachedSubscription(utils, queryClient, routeInfo.subscriptionId); + const subscription = collections.subscriptions.get(routeInfo.subscriptionId); if (subscription) { return ( {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} @@ -264,10 +262,9 @@ function TitleFallback({ routeInfo }: { routeInfo: RouteInfo }) { return ; } - // Tag title from cache + // Tag title from collection (O(1) lookup) if (routeInfo.tagId) { - const tagsData = utils.tags.list.getData(); - const tag = tagsData?.items.find((t) => t.id === routeInfo.tagId); + const tag = collections.tags.get(routeInfo.tagId); if (tag) { return {tag.name}; } From 983938350db5e790db490d963b205c643f9ecb96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 01:05:48 +0000 Subject: [PATCH 05/31] Remove React Query entry list cache updates, use TanStack DB (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entry list now reads mutable state (read, starred, title, etc.) from the TanStack DB entries collection via useLiveQuery, while tRPC infinite query pages still provide ordering and identity. This eliminates the need for complex React Query cache manipulation on entry state changes. Changes: - SuspendingEntryList: merge collection state over page items via useLiveQuery - useEntryMutations: replace cache operations with collection writes + entries.get setData - event-handlers: replace list cache updates with collection writes - operations.ts: remove entry state functions (handleEntriesMarkedRead, etc.) - entry-cache.ts: strip to only findParentListPlaceholderData (~808→363 LOC) - Update tests to remove tests for deleted functions Net: -1227 LOC of cache manipulation code Co-Authored-By: Claude Opus 4.6 --- .../entries/SuspendingEntryList.tsx | 47 +- src/lib/cache/entry-cache.ts | 465 +-------------- src/lib/cache/event-handlers.ts | 33 +- src/lib/cache/operations.ts | 267 +-------- src/lib/hooks/useEntryMutations.ts | 145 +++-- tests/unit/frontend/cache/operations.test.ts | 546 +----------------- .../frontend/hooks/useEntryMutations.test.ts | 47 +- 7 files changed, 161 insertions(+), 1389 deletions(-) diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index 6b3127b2..4b7c5c49 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -12,6 +12,7 @@ "use client"; import { useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useLiveQuery } from "@tanstack/react-db"; import { trpc } from "@/lib/trpc/client"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; @@ -60,26 +61,36 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) upsertEntriesInCollection(collections, allPageItems); }, [collections, allPageItems]); - // Flatten entries from all pages + // Reactive subscription to entries collection state. + // Re-renders when mutations/SSE call writeUpdate on the collection, + // providing live mutable state (read, starred) to overlay on page items. + const { state: entriesState } = useLiveQuery(collections.entries); + + // Build entries by merging page items (ordering/identity) with collection state + // (mutable fields like read/starred). The collection is the source of truth for + // mutable state after optimistic updates, while tRPC pages provide ordering. const entries = useMemo( () => - allPageItems.map((entry) => ({ - id: entry.id, - feedId: entry.feedId, - subscriptionId: entry.subscriptionId, - type: entry.type, - url: entry.url, - title: entry.title, - author: entry.author, - summary: entry.summary, - publishedAt: entry.publishedAt, - fetchedAt: entry.fetchedAt, - read: entry.read, - starred: entry.starred, - feedTitle: entry.feedTitle, - siteName: entry.siteName, - })), - [allPageItems] + allPageItems.map((entry) => { + const live = entriesState.get(entry.id); + return { + id: entry.id, + feedId: entry.feedId, + subscriptionId: entry.subscriptionId, + type: entry.type, + url: entry.url, + title: live?.title ?? entry.title, + author: live?.author ?? entry.author, + summary: live?.summary ?? entry.summary, + publishedAt: entry.publishedAt, + fetchedAt: entry.fetchedAt, + read: live?.read ?? entry.read, + starred: live?.starred ?? entry.starred, + feedTitle: entry.feedTitle, + siteName: entry.siteName, + }; + }), + [allPageItems, entriesState] ); // Compute next/previous entry IDs for keyboard navigation diff --git a/src/lib/cache/entry-cache.ts b/src/lib/cache/entry-cache.ts index 235fa826..546057dc 100644 --- a/src/lib/cache/entry-cache.ts +++ b/src/lib/cache/entry-cache.ts @@ -1,17 +1,15 @@ /** * Entry Cache Helpers * - * Functions for updating entry state in React Query cache. + * Provides placeholder data for Suspense fallbacks by looking up + * cached entry list data from React Query. * - * Strategy: - * - For individual entry views (entries.get): update directly - * - For entry lists (entries.list): update in place without invalidation - * (entries stay visible until navigation; SuspendingEntryList refetches on pathname change) - * - For counts (subscriptions, tags): update directly via count-cache helpers + * Note: Entry state updates (read, starred, score) are handled by + * TanStack DB collections. This file only contains the placeholder + * data lookup for EntryListFallback. */ import type { QueryClient } from "@tanstack/react-query"; -import type { TRPCClientUtils } from "@/lib/trpc/client"; /** * Entry data in list cache. @@ -20,6 +18,8 @@ interface CachedListEntry { id: string; read: boolean; starred: boolean; + subscriptionId?: string | null; + type?: string; [key: string]: unknown; } @@ -39,386 +39,6 @@ interface InfiniteData { pageParams: unknown[]; } -/** - * Updates entries in all cached entry lists (infinite queries). - * Uses QueryClient.setQueriesData to update all cached queries regardless of filters. - * - * @param queryClient - React Query client for cache access - * @param entryIds - Entry IDs to update - * @param updates - Fields to update (read, starred) - */ -export function updateEntriesInListCache( - queryClient: QueryClient, - entryIds: string[], - updates: Partial<{ read: boolean; starred: boolean; score: number | null; implicitScore: number }> -): void { - const entryIdSet = new Set(entryIds); - - // Update all cached entry list queries (matches any query starting with ['entries', 'list']) - queryClient.setQueriesData({ queryKey: [["entries", "list"]] }, (oldData) => { - if (!oldData?.pages) return oldData; - - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entryIdSet.has(entry.id)) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }; - }); -} - -/** - * Entry context for targeted cache updates. - */ -export interface EntryContext { - id: string; - subscriptionId: string | null; - type: "web" | "email" | "saved"; - starred: boolean; -} - -/** - * Affected scope info for targeted cache updates. - */ -export interface AffectedScope { - tagIds: Set; - hasUncategorized: boolean; -} - -/** - * Updates entries in only the affected cached entry lists. - * Uses the entry context and affected scope to skip unrelated caches. - * - * @param queryClient - React Query client for cache access - * @param entries - Entries with their context - * @param updates - Fields to update (read, starred) - * @param scope - Affected tags and uncategorized flag from server response - */ -export function updateEntriesInAffectedListCaches( - queryClient: QueryClient, - entries: EntryContext[], - updates: Partial<{ read: boolean }>, - scope: AffectedScope -): void { - if (entries.length === 0) return; - - const entryIdSet = new Set(entries.map((e) => e.id)); - const subscriptionIds = new Set(entries.map((e) => e.subscriptionId).filter(Boolean) as string[]); - const entryTypes = new Set(entries.map((e) => e.type)); - const hasStarred = entries.some((e) => e.starred); - - // Get all cached entry list queries - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - // Extract filters from query key - const keyMeta = queryKey[1] as TRPCQueryKey | undefined; - const filters: EntryListFilters = keyMeta?.input ?? {}; - - // Check if this cache could contain any of the affected entries - if (!shouldUpdateEntryListCache(filters, subscriptionIds, entryTypes, hasStarred, scope)) { - continue; - } - - // Update entries in this cache - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entryIdSet.has(entry.id)) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }); - } -} - -/** - * Determines if an entry list cache should be updated based on its filters - * and the affected entries' context. - */ -function shouldUpdateEntryListCache( - filters: EntryListFilters, - subscriptionIds: Set, - entryTypes: Set, - hasStarred: boolean, - scope: AffectedScope -): boolean { - // No filters = All entries view, always update - const hasNoFilters = - !filters.subscriptionId && - !filters.tagId && - !filters.uncategorized && - !filters.starredOnly && - !filters.type; - if (hasNoFilters) return true; - - // Subscription filter: only update if an affected entry is in this subscription - if (filters.subscriptionId) { - if (!subscriptionIds.has(filters.subscriptionId)) return false; - } - - // Tag filter: only update if this tag was affected - if (filters.tagId) { - if (!scope.tagIds.has(filters.tagId)) return false; - } - - // Uncategorized filter: only update if uncategorized entries were affected - if (filters.uncategorized) { - if (!scope.hasUncategorized) return false; - } - - // Starred filter: only update if any affected entry is starred - if (filters.starredOnly) { - if (!hasStarred) return false; - } - - // Type filter: only update if an affected entry has this type - if (filters.type) { - if (!entryTypes.has(filters.type)) return false; - } - - return true; -} - -/** - * Updates read status for entries in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Does NOT invalidate/refetch - entries stay visible until navigation. - * - * Note: Call adjustSubscriptionUnreadCounts and adjustTagUnreadCounts separately - * for count updates - those update directly without invalidation. - * - * @param utils - tRPC utils for cache access - * @param entryIds - Entry IDs to update - * @param read - New read status - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntriesReadStatus( - utils: TRPCClientUtils, - entryIds: string[], - read: boolean, - queryClient?: QueryClient -): void { - // Update individual entries.get caches - these are keyed by entry ID - for (const entryId of entryIds) { - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, read }, - }; - }); - } - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, entryIds, { read }); - } -} - -/** - * Updates starred status for an entry in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Does NOT invalidate/refetch - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param starred - New starred status - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryStarredStatus( - utils: TRPCClientUtils, - entryId: string, - starred: boolean, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, starred }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, [entryId], { starred }); - } -} - -/** - * Entry metadata that can be updated from SSE events. - */ -export interface EntryMetadataUpdate { - title?: string | null; - author?: string | null; - summary?: string | null; - url?: string | null; - publishedAt?: Date | null; -} - -/** - * Updates entry metadata in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Used when entry content changes (e.g., feed refetch, saved article refresh). - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param metadata - New metadata values - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryMetadataInCache( - utils: TRPCClientUtils, - entryId: string, - metadata: EntryMetadataUpdate, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { - ...oldData.entry, - ...(metadata.title !== undefined && { title: metadata.title }), - ...(metadata.author !== undefined && { author: metadata.author }), - ...(metadata.summary !== undefined && { summary: metadata.summary }), - ...(metadata.url !== undefined && { url: metadata.url }), - ...(metadata.publishedAt !== undefined && { publishedAt: metadata.publishedAt }), - }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - const updates: Partial = {}; - if (metadata.title !== undefined) updates.title = metadata.title; - if (metadata.author !== undefined) updates.author = metadata.author; - if (metadata.summary !== undefined) updates.summary = metadata.summary; - if (metadata.url !== undefined) updates.url = metadata.url; - if (metadata.publishedAt !== undefined) updates.publishedAt = metadata.publishedAt; - - if (Object.keys(updates).length > 0) { - queryClient.setQueriesData({ queryKey: [["entries", "list"]] }, (oldData) => { - if (!oldData?.pages) return oldData; - - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entry.id === entryId) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }; - }); - } - } -} - -/** - * Updates score fields for an entry in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param score - New explicit score (null to clear) - * @param implicitScore - New implicit score - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryScoreInCache( - utils: TRPCClientUtils, - entryId: string, - score: number | null, - implicitScore: number, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, score, implicitScore }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, [entryId], { score, implicitScore }); - } -} - -/** - * Entry data from the list (lightweight, no content). - */ -export interface EntryListItem { - id: string; - feedId: string; - subscriptionId: string | null; - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - author: string | null; - summary: string | null; - publishedAt: Date | null; - fetchedAt: Date; - updatedAt: Date; - read: boolean; - starred: boolean; - feedTitle: string | null; - score: number | null; - implicitScore: number; -} - -/** - * Finds an entry in the cached entry lists by ID. - * Searches through all cached infinite query pages. - * - * @param queryClient - React Query client for cache access - * @param entryId - Entry ID to find - * @returns The entry if found, undefined otherwise - */ -export function findEntryInListCache( - queryClient: QueryClient, - entryId: string -): EntryListItem | undefined { - // Get all cached entry list queries - const queries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - for (const [, data] of queries) { - if (!data?.pages) continue; - for (const page of data.pages) { - const entry = page.items.find((e) => e.id === entryId); - if (entry) { - // The cache contains full EntryListItem data, but TypeScript only sees CachedListEntry - return entry as unknown as EntryListItem; - } - } - } - - return undefined; -} - /** * Filter options for entry list queries. */ @@ -462,7 +82,6 @@ interface SubscriptionInfiniteData { * Returns undefined if not cached. */ function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] | undefined { - // Look up subscriptions.list cache - handles both query and infinite query formats const queries = queryClient.getQueriesData({ queryKey: [["subscriptions", "list"]], }); @@ -473,7 +92,6 @@ function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] for (const [, data] of queries) { if (!data) continue; - // Check if it's infinite query format (has pages array) if ("pages" in data && Array.isArray(data.pages)) { for (const page of data.pages) { if (page?.items) { @@ -485,9 +103,7 @@ function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] } } } - } - // Regular query format (has items directly) - else if ("items" in data && Array.isArray(data.items)) { + } else if ("items" in data && Array.isArray(data.items)) { for (const sub of data.items) { if (!seenIds.has(sub.id)) { seenIds.add(sub.id); @@ -502,7 +118,6 @@ function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] /** * Query key structure for tRPC infinite queries. - * The key is [["entries", "list"], { input: {...}, type: "infinite" }] */ interface TRPCQueryKey { input?: EntryListFilters & { limit?: number; cursor?: string }; @@ -511,53 +126,29 @@ interface TRPCQueryKey { /** * Checks if a parent query's filters are compatible for use as placeholder data. - * A parent is compatible if it's a superset of the requested filters. - * - * @param parentFilters - Filters from the parent query - * @param requestedFilters - Filters being requested - * @returns true if parent can provide placeholder data for the request */ function areFiltersCompatible( parentFilters: EntryListFilters, requestedFilters: EntryListFilters ): boolean { - // Sort order must match (we can't reorder entries client-side) const parentSort = parentFilters.sortOrder ?? "newest"; const requestedSort = requestedFilters.sortOrder ?? "newest"; if (parentSort !== requestedSort) return false; - - // If parent has starredOnly=true, we can only use it for starred requests if (parentFilters.starredOnly && !requestedFilters.starredOnly) return false; - - // If parent has unreadOnly=true, we can only use it for unread requests if (parentFilters.unreadOnly && !requestedFilters.unreadOnly) return false; - - // If parent has a type filter, it must match (or be omitted) if (parentFilters.type && parentFilters.type !== requestedFilters.type) return false; - - // If parent has subscriptionId, it must match if ( parentFilters.subscriptionId && parentFilters.subscriptionId !== requestedFilters.subscriptionId ) return false; - - // If parent has tagId, it must match if (parentFilters.tagId && parentFilters.tagId !== requestedFilters.tagId) return false; - - // If parent has uncategorized, it must match if (parentFilters.uncategorized && !requestedFilters.uncategorized) return false; - return true; } /** * Filters entries from a parent list to match the requested filters. - * - * @param entries - Entries from the parent list - * @param filters - Requested filters to apply - * @param subscriptions - Subscription data for tag filtering - * @returns Filtered entries */ function filterEntries( entries: CachedListEntry[], @@ -566,12 +157,10 @@ function filterEntries( ): CachedListEntry[] { let result = entries; - // Filter by subscriptionId if (filters.subscriptionId) { result = result.filter((e) => e.subscriptionId === filters.subscriptionId); } - // Filter by tagId (need subscriptions data to know which subscriptions have this tag) if (filters.tagId && subscriptions) { const subscriptionIdsInTag = new Set( subscriptions @@ -583,7 +172,6 @@ function filterEntries( ); } - // Filter by uncategorized (subscriptions with no tags) if (filters.uncategorized && subscriptions) { const uncategorizedSubscriptionIds = new Set( subscriptions.filter((sub) => sub.tags.length === 0).map((sub) => sub.id) @@ -593,17 +181,14 @@ function filterEntries( ); } - // Filter by starredOnly if (filters.starredOnly) { result = result.filter((e) => e.starred); } - // Filter by unreadOnly if (filters.unreadOnly) { result = result.filter((e) => !e.read); } - // Filter by type if (filters.type) { result = result.filter((e) => e.type === filters.type); } @@ -613,7 +198,6 @@ function filterEntries( /** * Entry list item structure for placeholder data. - * Matches the schema returned by entries.list tRPC procedure. */ interface EntryListItemForPlaceholder { id: string; @@ -635,35 +219,24 @@ interface EntryListItemForPlaceholder { implicitScore: number; } -/** - * Page structure for typed placeholder data. - */ interface TypedPage { items: EntryListItemForPlaceholder[]; nextCursor?: string; } -/** - * Typed infinite data for placeholder. - */ interface TypedInfiniteData { pages: TypedPage[]; pageParams: (string | undefined)[]; } -/** - * Finds a cached query matching specific filters. - */ function findCachedQuery( queries: [readonly unknown[], InfiniteData | undefined][], matchFilters: (parentFilters: EntryListFilters) => boolean ): InfiniteData | undefined { for (const [queryKey, data] of queries) { if (!data?.pages?.length) continue; - const keyMeta = queryKey[1] as TRPCQueryKey | undefined; const parentFilters: EntryListFilters = keyMeta?.input ?? {}; - if (matchFilters(parentFilters)) { return data; } @@ -671,16 +244,11 @@ function findCachedQuery( return undefined; } -/** - * Finds a cached query with preference for exact unreadOnly/starredOnly match. - * First tries exact match, then falls back to any compatible query. - */ function findCachedQueryWithPreference( queries: [readonly unknown[], InfiniteData | undefined][], baseMatch: (pf: EntryListFilters) => boolean, filters: EntryListFilters ): InfiniteData | undefined { - // First try exact match (same unreadOnly/starredOnly) let result = findCachedQuery( queries, (pf) => @@ -690,7 +258,6 @@ function findCachedQueryWithPreference( areFiltersCompatible(pf, filters) ); - // Fall back to any compatible match if (!result) { result = findCachedQuery(queries, (pf) => baseMatch(pf) && areFiltersCompatible(pf, filters)); } @@ -698,9 +265,6 @@ function findCachedQueryWithPreference( return result; } -/** - * Checks if two filter sets are exactly equal (for self-cache lookup). - */ function filtersEqual(a: EntryListFilters, b: EntryListFilters): boolean { return ( a.subscriptionId === b.subscriptionId && @@ -733,25 +297,20 @@ export function findParentListPlaceholderData( queryClient: QueryClient, filters: EntryListFilters ): TypedInfiniteData | undefined { - // Look up subscriptions from cache for tag/uncategorized filtering - // Note: subscriptionId filtering doesn't need the subscriptions cache - it filters directly by entry.subscriptionId const needsSubscriptions = filters.tagId || filters.uncategorized; const subscriptions = needsSubscriptions ? getSubscriptionsFromCache(queryClient) : undefined; - // If we need subscriptions for filtering but can't find them, show skeleton - // rather than showing incorrect/unfiltered data if (needsSubscriptions && !subscriptions) { return undefined; } + const queries = queryClient.getQueriesData({ queryKey: [["entries", "list"]], }); // 1. Check for exact match first (self-cache) - // This handles "All" using its own cache when navigating back, etc. const exactMatch = findCachedQuery(queries, (pf) => filtersEqual(pf, filters)); if (exactMatch) { - // Return cached data directly, no filtering needed return { pages: exactMatch.pages.map((p) => ({ items: p.items as unknown as EntryListItemForPlaceholder[], @@ -778,7 +337,7 @@ export function findParentListPlaceholderData( } } - // 3. Fall back to "All" list (no subscription/tag/uncategorized filters) + // 3. Fall back to "All" list if (!parentData) { parentData = findCachedQueryWithPreference( queries, @@ -789,15 +348,11 @@ export function findParentListPlaceholderData( if (!parentData) return undefined; - // Filter the parent's entries to match the requested filters const allEntries = parentData.pages.flatMap((page) => page.items); const filteredEntries = filterEntries(allEntries, filters, subscriptions); - // No matching entries found if (filteredEntries.length === 0) return undefined; - // Return in infinite query format (single page, no cursor for placeholder) - // Cast is safe because the cache contains the full entry data return { pages: [ { items: filteredEntries as unknown as EntryListItemForPlaceholder[], nextCursor: undefined }, diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 54f9f23a..9317d9e1 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -10,7 +10,6 @@ import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; import type { Collections } from "@/lib/collections"; import { handleSubscriptionCreated, handleSubscriptionDeleted, handleNewEntry } from "./operations"; -import { updateEntriesInListCache, updateEntryMetadataInCache } from "./entry-cache"; import { applySyncTagChanges, removeSyncTags } from "./count-cache"; import { addTagToCollection, @@ -197,7 +196,7 @@ export function handleSyncEvent( break; case "entry_updated": { - // Update entry metadata directly in caches + // Update entry metadata in entries.get cache (detail view) const metadata = { title: event.metadata.title, author: event.metadata.author, @@ -205,19 +204,35 @@ export function handleSyncEvent( url: event.metadata.url, publishedAt: event.metadata.publishedAt ? new Date(event.metadata.publishedAt) : null, }; - updateEntryMetadataInCache(utils, event.entryId, metadata, queryClient); - // Also update entries collection + utils.entries.get.setData({ id: event.entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + ...(metadata.title !== undefined && { title: metadata.title }), + ...(metadata.author !== undefined && { author: metadata.author }), + ...(metadata.summary !== undefined && { summary: metadata.summary }), + ...(metadata.url !== undefined && { url: metadata.url }), + ...(metadata.publishedAt !== undefined && { publishedAt: metadata.publishedAt }), + }, + }; + }); + // Update entries collection (list view, via reactive useLiveQuery) updateEntryMetadataInCollection(collections ?? null, event.entryId, metadata); break; } case "entry_state_changed": - // Update read/starred state in cache - updateEntriesInListCache(queryClient, [event.entryId], { - read: event.read, - starred: event.starred, + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: event.entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { ...oldData.entry, read: event.read, starred: event.starred }, + }; }); - // Also update entries collection + // Update entries collection (list view, via reactive useLiveQuery) updateEntryReadInCollection(collections ?? null, [event.entryId], event.read); updateEntryStarredInCollection(collections ?? null, event.entryId, event.starred); break; diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index 7ad78fbc..668ef3cd 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -1,23 +1,14 @@ /** * Cache Operations * - * Higher-level functions for cache updates that handle all the interactions - * between different caches. These are the primary API for mutations and SSE - * handlers - they don't need to know which low-level caches to update. - * - * Operations look up entry state from cache to handle interactions correctly: - * - Starring an unread entry affects the starred unread count - * - Marking a starred entry read affects the starred unread count + * Higher-level functions for subscription/count cache updates. + * Entry state (read, starred, score) is managed by TanStack DB collections. + * These operations handle subscription lifecycle and count updates. */ import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; import type { Collections, Subscription } from "@/lib/collections"; -import { - updateEntriesReadStatus, - updateEntryStarredStatus, - updateEntryScoreInCache, -} from "./entry-cache"; import { adjustSubscriptionUnreadCounts, adjustTagUnreadCounts, @@ -40,22 +31,6 @@ import { adjustUncategorizedFeedCountInCollection, } from "@/lib/collections/writes"; -/** - * Entry type (matches feed type schema). - */ -export type EntryType = "web" | "email" | "saved"; - -/** - * Entry with context, as returned by markRead mutation. - * Includes all state needed for cache updates. - */ -export interface EntryWithContext { - id: string; - subscriptionId: string | null; - starred: boolean; - type: EntryType; -} - /** * Subscription data for adding to cache. */ @@ -213,156 +188,6 @@ function removeSubscriptionFromInfiniteQueries( } } -/** - * Handles entries being marked as read or unread. - * - * Updates: - * - entries.get cache for each entry - * - entries.list cache (updates in place, no refetch) - * - subscriptions.list unread counts - * - tags.list unread counts - * - entries.count({ starredOnly: true }) for starred entries - * - entries.count({ type: "saved" }) for saved entries - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * SuspendingEntryList refetches on pathname change, so lists update on next navigation. - * - * @param utils - tRPC utils for cache access - * @param entries - Entries with their context (subscriptionId, starred, type) - * @param read - New read status - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntriesMarkedRead( - utils: TRPCClientUtils, - entries: EntryWithContext[], - read: boolean, - queryClient?: QueryClient, - collections?: Collections | null -): void { - if (entries.length === 0) return; - - // 1. Update entry read status in entries.get cache and entries.list cache - updateEntriesReadStatus( - utils, - entries.map((e) => e.id), - read, - queryClient - ); - - // 2. Calculate subscription deltas - // Marking read: -1, marking unread: +1 - const delta = read ? -1 : 1; - const subscriptionDeltas = new Map(); - - for (const entry of entries) { - if (entry.subscriptionId) { - const current = subscriptionDeltas.get(entry.subscriptionId) ?? 0; - subscriptionDeltas.set(entry.subscriptionId, current + delta); - } - } - - // 3. Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient, collections); - - // 4. Update All Articles unread count - adjustEntriesCount(utils, {}, delta * entries.length); - - // 5. Update starred unread count - only for entries that are starred - const starredCount = entries.filter((e) => e.starred).length; - if (starredCount > 0) { - adjustEntriesCount(utils, { starredOnly: true }, delta * starredCount); - } - - // 6. Update saved unread count - only for saved entries - const savedCount = entries.filter((e) => e.type === "saved").length; - if (savedCount > 0) { - adjustEntriesCount(utils, { type: "saved" }, delta * savedCount); - } -} - -/** - * Handles an entry being starred. - * - * Updates: - * - entries.get cache - * - entries.list cache (updates in place, no refetch) - * - entries.count({ starredOnly: true }) - total +1, unread +1 if entry is unread - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID being starred - * @param read - Whether the entry is read (from server response) - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryStarred( - utils: TRPCClientUtils, - entryId: string, - read: boolean, - queryClient?: QueryClient -): void { - // 1. Update entry starred status - updateEntryStarredStatus(utils, entryId, true, queryClient); - - // 2. Update starred count - // Total always +1, unread +1 only if entry is unread - adjustEntriesCount(utils, { starredOnly: true }, read ? 0 : 1, 1); -} - -/** - * Handles an entry being unstarred. - * - * Updates: - * - entries.get cache - * - entries.list cache (updates in place, no refetch) - * - entries.count({ starredOnly: true }) - total -1, unread -1 if entry is unread - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID being unstarred - * @param read - Whether the entry is read (from server response) - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryUnstarred( - utils: TRPCClientUtils, - entryId: string, - read: boolean, - queryClient?: QueryClient -): void { - // 1. Update entry starred status - updateEntryStarredStatus(utils, entryId, false, queryClient); - - // 2. Update starred count - // Total always -1, unread -1 only if entry was unread - adjustEntriesCount(utils, { starredOnly: true }, read ? 0 : -1, -1); -} - -/** - * Handles an entry's score being changed. - * - * Updates: - * - entries.get cache (score, implicitScore) - * - entries.list cache (score, implicitScore) - * - * Score changes don't affect unread counts, subscription counts, or tag counts. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID whose score changed - * @param score - New explicit score (null if cleared) - * @param implicitScore - New implicit score - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryScoreChanged( - utils: TRPCClientUtils, - entryId: string, - score: number | null, - implicitScore: number, - queryClient?: QueryClient -): void { - updateEntryScoreInCache(utils, entryId, score, implicitScore, queryClient); -} - /** * Handles a new subscription being created. * @@ -890,89 +715,3 @@ function setUncategorizedUnreadCount(utils: TRPCClientUtils, unread: number): vo }; }); } - -// ============================================================================ -// Optimistic Update Helpers -// ============================================================================ - -/** - * Context returned by optimistic read update for rollback. - */ -export interface OptimisticReadContext { - previousEntries: Map; - entryIds: string[]; -} - -/** - * Prepares and applies an optimistic read status update. - * Should be called in onMutate. Returns context needed for rollback in onError. - * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache access - * @param entryIds - Entry IDs to update - * @param read - New read status - * @returns Context for rollback - */ -export async function applyOptimisticReadUpdate( - utils: TRPCClientUtils, - queryClient: QueryClient, - entryIds: string[], - read: boolean -): Promise { - // We intentionally don't cancel any queries here. - // - Cancelling entries.get would abort content fetches, leaving only placeholder data - // - Cancelling entries.list would disrupt scrolling/loading while marking entries read - // - If a fetch completes with stale read status, onSuccess will correct it immediately - // - The race condition window is small (between onMutate and onSuccess) - - // Snapshot the previous state for rollback - const previousEntries = new Map(); - for (const entryId of entryIds) { - const data = utils.entries.get.getData({ id: entryId }); - previousEntries.set(entryId, data?.entry ? { read: data.entry.read } : undefined); - } - - // Optimistically update the cache immediately - updateEntriesReadStatus(utils, entryIds, read, queryClient); - - return { previousEntries, entryIds }; -} - -/** - * Context returned by optimistic starred update for rollback. - */ -export interface OptimisticStarredContext { - entryId: string; - wasStarred: boolean; -} - -/** - * Prepares and applies an optimistic starred status update. - * Should be called in onMutate. Returns context needed for rollback in onError. - * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache access - * @param entryId - Entry ID to update - * @param starred - New starred status - * @returns Context for rollback - */ -export async function applyOptimisticStarredUpdate( - utils: TRPCClientUtils, - queryClient: QueryClient, - entryId: string, - starred: boolean -): Promise { - // We intentionally don't cancel any queries here. - // - Cancelling entries.get would abort content fetches, leaving only placeholder data - // - Cancelling entries.list would disrupt scrolling/loading - // - If a fetch completes with stale starred status, onSuccess will correct it immediately - - // Snapshot previous state for rollback - const previousEntry = utils.entries.get.getData({ id: entryId }); - const wasStarred = previousEntry?.entry?.starred ?? !starred; - - // Optimistically update the cache - updateEntryStarredStatus(utils, entryId, starred, queryClient); - - return { entryId, wasStarred }; -} diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index b045e19d..28512ca5 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -24,19 +24,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; -import { - handleEntryScoreChanged, - setCounts, - setBulkCounts, - applyOptimisticReadUpdate, - applyOptimisticStarredUpdate, -} from "@/lib/cache/operations"; -import { - updateEntriesReadStatus, - updateEntryStarredStatus, - updateEntriesInListCache, - updateEntriesInAffectedListCaches, -} from "@/lib/cache/entry-cache"; +import { setCounts, setBulkCounts } from "@/lib/cache/operations"; import { updateEntryReadInCollection, updateEntryStarredInCollection, @@ -256,6 +244,7 @@ export function useEntryMutations(): UseEntryMutationsResult { /** * Apply winning state to cache if it's newer than current cache state. + * Updates entries.get (detail view) and collection (list view). */ const applyWinningStateToCache = (entryId: string, winningState: MutationResultState) => { // Get current cache state to compare timestamps @@ -268,18 +257,22 @@ export function useEntryMutations(): UseEntryMutationsResult { return; } - // Update the cache with winning state - updateEntriesReadStatus(utils, [entryId], winningState.read); - updateEntryStarredStatus(utils, entryId, winningState.starred, queryClient); - handleEntryScoreChanged( - utils, - entryId, - winningState.score, - winningState.implicitScore, - queryClient - ); + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + read: winningState.read, + starred: winningState.starred, + score: winningState.score, + implicitScore: winningState.implicitScore, + }, + }; + }); - // Update entries collection with winning state + // Update entries collection (list view, via reactive useLiveQuery) updateEntryReadInCollection(collections, [entryId], winningState.read); updateEntryStarredInCollection(collections, entryId, winningState.starred); updateEntryScoreInCollection( @@ -297,26 +290,34 @@ export function useEntryMutations(): UseEntryMutationsResult { onMutate: async (variables) => { const entryIds = variables.entries.map((e) => e.id); - const optimisticContext = await applyOptimisticReadUpdate( - utils, - queryClient, - entryIds, - variables.read - ); + // Snapshot previous state for rollback + const previousEntries = new Map(); + for (const entryId of entryIds) { + const data = utils.entries.get.getData({ id: entryId }); + previousEntries.set(entryId, data?.entry ? { read: data.entry.read } : undefined); + } + + // Optimistically update entries.get cache (detail view) + for (const entryId of entryIds) { + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, read: variables.read } }; + }); + } - // Optimistic update in entries collection + // Optimistic update in entries collection (list view, via reactive useLiveQuery) updateEntryReadInCollection(collections, entryIds, variables.read); // Start tracking for each entry for (const entryId of entryIds) { - const prevEntry = optimisticContext.previousEntries.get(entryId); + const prevEntry = previousEntries.get(entryId); const originalRead = prevEntry?.read ?? false; const cachedData = utils.entries.get.getData({ id: entryId }); const originalStarred = cachedData?.entry?.starred ?? false; startTracking(entryId, originalRead, originalStarred); } - return optimisticContext; + return { previousEntries, entryIds }; }, onSuccess: (data) => { @@ -337,20 +338,7 @@ export function useEntryMutations(): UseEntryMutationsResult { } } - // Update list caches with read status from server response - const scope = { - tagIds: new Set(data.counts.tags.map((t) => t.id)), - hasUncategorized: data.counts.uncategorized !== undefined, - }; - // All entries in a markRead batch have the same read value - updateEntriesInAffectedListCaches( - queryClient, - data.entries, - { read: data.entries[0]?.read ?? false }, - scope - ); - - // Update entries in collection with server state + // Update entries in collection with server state (read + score) for (const entry of data.entries) { updateEntryReadInCollection(collections, [entry.id], entry.read); updateEntryScoreInCollection(collections, entry.id, entry.score, entry.implicitScore); @@ -372,9 +360,11 @@ export function useEntryMutations(): UseEntryMutationsResult { // Some mutations succeeded, apply winning state applyWinningStateToCache(entryId, result.winningState); } else { - // All mutations failed, rollback to original state from tracking - // (not from context, which may have captured intermediate state) - updateEntriesReadStatus(utils, [entryId], result.originalRead, queryClient); + // All mutations failed, rollback to original state + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, read: result.originalRead } }; + }); updateEntryReadInCollection(collections, [entryId], result.originalRead); } } @@ -413,23 +403,26 @@ export function useEntryMutations(): UseEntryMutationsResult { const setStarredMutation = trpc.entries.setStarred.useMutation({ // Optimistic update: immediately show the entry with new starred status onMutate: async (variables) => { - const optimisticContext = await applyOptimisticStarredUpdate( - utils, - queryClient, - variables.id, - variables.starred - ); + // Snapshot previous state for rollback + const previousEntry = utils.entries.get.getData({ id: variables.id }); + const wasStarred = previousEntry?.entry?.starred ?? !variables.starred; + + // Optimistically update entries.get cache (detail view) + utils.entries.get.setData({ id: variables.id }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, starred: variables.starred } }; + }); - // Optimistic update in entries collection + // Optimistic update in entries collection (list view, via reactive useLiveQuery) updateEntryStarredInCollection(collections, variables.id, variables.starred); // Start tracking - const originalStarred = optimisticContext.wasStarred; + const originalStarred = wasStarred; const cachedData = utils.entries.get.getData({ id: variables.id }); const originalRead = cachedData?.entry?.read ?? false; startTracking(variables.id, originalRead, originalStarred); - return optimisticContext; + return { entryId: variables.id, wasStarred }; }, onSuccess: (data) => { @@ -447,10 +440,7 @@ export function useEntryMutations(): UseEntryMutationsResult { applyWinningStateToCache(data.entry.id, winningState); } - // Update list cache with starred status - updateEntriesInListCache(queryClient, [data.entry.id], { starred: data.entry.starred }); - - // Update entries collection with server state + // Update entries collection with server state (starred + score) updateEntryStarredInCollection(collections, data.entry.id, data.entry.starred); updateEntryScoreInCollection( collections, @@ -470,9 +460,11 @@ export function useEntryMutations(): UseEntryMutationsResult { if (result.winningState) { applyWinningStateToCache(variables.id, result.winningState); } else { - // All mutations failed, rollback to original state from tracking - // (not from context, which may have captured intermediate state) - updateEntryStarredStatus(utils, variables.id, result.originalStarred, queryClient); + // All mutations failed, rollback to original state + utils.entries.get.setData({ id: variables.id }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, starred: result.originalStarred } }; + }); updateEntryStarredInCollection(collections, variables.id, result.originalStarred); } } @@ -483,15 +475,20 @@ export function useEntryMutations(): UseEntryMutationsResult { // setScore mutation - updates score cache only (no count changes) const setScoreMutation = trpc.entries.setScore.useMutation({ onSuccess: (data) => { - handleEntryScoreChanged( - utils, - data.entry.id, - data.entry.score, - data.entry.implicitScore, - queryClient - ); + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: data.entry.id }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + score: data.entry.score, + implicitScore: data.entry.implicitScore, + }, + }; + }); - // Update entries collection with score + // Update entries collection (list view) updateEntryScoreInCollection( collections, data.entry.id, diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index e945f4be..1fe7a091 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -4,190 +4,19 @@ * These tests document the behavior of cache operations without full mocking. * For more comprehensive testing, see the integration tests. * - * Note: The cache operations call lower-level helpers that interact with - * tRPC utils in complex ways. These tests focus on high-level behavior - * that can be tested with the mock utils. + * Note: Entry state (read, starred, score) is managed by TanStack DB collections. + * These tests cover subscription lifecycle and count update operations. */ import { describe, it, expect, beforeEach } from "vitest"; import { createMockTrpcUtils } from "../../../utils/trpc-mock"; import { - handleEntriesMarkedRead, - handleEntryStarred, - handleEntryUnstarred, handleNewEntry, handleSubscriptionCreated, handleSubscriptionDeleted, - type EntryWithContext, type SubscriptionData, } from "@/lib/cache/operations"; -describe("handleEntriesMarkedRead", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("does nothing for empty entries array", () => { - handleEntriesMarkedRead(mockUtils.utils, [], true); - expect(mockUtils.operations).toHaveLength(0); - }); - - it("updates entry read status in entries.get cache", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(setDataOps.length).toBeGreaterThan(0); - }); - - it("updates subscription unread counts", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-2", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on subscriptions.list - const subOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(subOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count for starred entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: true, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on entries.count with starredOnly filter - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates saved unread count for saved entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on entries.count with type: "saved" filter - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("handles entries without subscription (saved articles)", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: null, starred: false, type: "saved" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should still update entries.get - const entryOps = mockUtils.operations.filter((op) => op.router === "entries"); - expect(entryOps.length).toBeGreaterThan(0); - }); -}); - -describe("handleEntryStarred", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("updates entry starred status", () => { - handleEntryStarred(mockUtils.utils, "entry-1", false); - - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("updates starred count - total always +1", () => { - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count +1 for unread entry", () => { - handleEntryStarred(mockUtils.utils, "entry-1", false); // unread entry - - // The unread delta should be +1 - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("does not change starred unread count for read entry", () => { - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - // The unread delta should be 0 (only total changes) - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); -}); - -describe("handleEntryUnstarred", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("updates entry starred status to false", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", false); - - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("updates starred count - total always -1", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", true); // read entry - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count -1 for unread entry", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - // The unread delta should be -1 - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); -}); - describe("handleNewEntry", () => { let mockUtils: ReturnType; @@ -452,108 +281,6 @@ describe("cache update logic verification", () => { mockUtils = createMockTrpcUtils(); }); - describe("handleEntriesMarkedRead cache state updates", () => { - it("decrements unread count when marking entries as read", () => { - // Set up initial cache state - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(3); - }); - - it("increments unread count when marking entries as unread", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 3, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, false); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(4); - }); - - it("updates multiple subscriptions independently", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 10, tags: [] }, - { id: "sub-2", unreadCount: 5, tags: [] }, - ], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-2", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(8); // sub-1: 10 - 2 - expect(cachedData.items[1].unreadCount).toBe(4); // sub-2: 5 - 1 - }); - - it("does not decrement unread count below zero", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 1, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(0); // Clamped to 0 - }); - - it("updates tag unread counts based on subscription tags", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 5, tags: [{ id: "tag-1", name: "News", color: null }] }, - ], - }); - mockUtils.setCache("tags", "list", undefined, { - items: [{ id: "tag-1", name: "News", color: null, unreadCount: 10 }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const tagData = mockUtils.getCache("tags", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(tagData.items[0].unreadCount).toBe(9); - }); - }); - describe("handleNewEntry cache state updates", () => { it("increments subscription unread count", () => { mockUtils.setCache("subscriptions", "list", undefined, { @@ -590,75 +317,6 @@ describe("cache update logic verification", () => { expect(tagData.items[0].unreadCount).toBe(11); }); }); - - describe("handleEntryStarred cache state updates", () => { - it("updates starred count total and unread for unread entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryStarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(6); // +1 - expect(countData.unread).toBe(3); // +1 - }); - - it("updates only starred count total for read entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(6); // +1 - expect(countData.unread).toBe(2); // unchanged - }); - }); - - describe("handleEntryUnstarred cache state updates", () => { - it("decrements starred count total and unread for unread entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(4); // -1 - expect(countData.unread).toBe(1); // -1 - }); - - it("decrements only starred count total for read entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", true); // read entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(4); // -1 - expect(countData.unread).toBe(2); // unchanged - }); - - it("does not decrement below zero", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 0, unread: 0 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(0); // clamped - expect(countData.unread).toBe(0); // clamped - }); - }); }); describe("edge cases", () => { @@ -669,210 +327,10 @@ describe("edge cases", () => { }); describe("null/undefined handling", () => { - it("handleEntriesMarkedRead handles entries with null subscriptionId", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: null, starred: false, type: "saved" }, - { id: "entry-2", subscriptionId: null, starred: true, type: "saved" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should still update entries.get - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("handleEntriesMarkedRead handles mixed null and non-null subscriptionIds", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: null, starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - // Only sub-1 should be decremented - expect(cachedData.items[0].unreadCount).toBe(4); - }); - - it("handleEntriesMarkedRead handles when subscriptions cache is undefined", () => { - // Don't set up subscriptions cache - leave it undefined - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - }); - it("handleNewEntry handles when subscriptions cache is undefined", () => { // Don't set up subscriptions cache - leave it undefined // Should not throw handleNewEntry(mockUtils.utils, "sub-1", "web"); }); }); - - describe("empty arrays", () => { - it("handleEntriesMarkedRead with empty array does nothing", () => { - handleEntriesMarkedRead(mockUtils.utils, [], true); - expect(mockUtils.operations).toHaveLength(0); - }); - - it("handleEntriesMarkedRead with empty array does not update subscriptions", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - handleEntriesMarkedRead(mockUtils.utils, [], true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(5); // unchanged - }); - }); - - describe("boundary conditions", () => { - it("handles very large unread count", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 999999, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(999998); - }); - - it("handles many entries being marked at once", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 100, tags: [] }], - }); - - const entries: EntryWithContext[] = Array.from({ length: 50 }, (_, i) => ({ - id: `entry-${i}`, - subscriptionId: "sub-1", - starred: false, - type: "web" as const, - })); - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(50); - }); - - it("handles entry belonging to non-existent subscription", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "non-existent", starred: false, type: "web" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // sub-1 should be unchanged - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(5); - }); - }); - - describe("all entry types", () => { - it("handles web type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should not update saved count - const countOps = mockUtils.operations.filter( - (op) => - op.type === "setData" && - op.router === "entries" && - op.procedure === "count" && - JSON.stringify((op.input as { type?: string })?.type) === '"saved"' - ); - expect(countOps.length).toBe(0); - }); - - it("handles email type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "email" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should not update saved count - const countOps = mockUtils.operations.filter( - (op) => - op.type === "setData" && - op.router === "entries" && - op.procedure === "count" && - JSON.stringify((op.input as { type?: string })?.type) === '"saved"' - ); - expect(countOps.length).toBe(0); - }); - - it("handles saved type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should update saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("handles mix of all entry types", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 10, tags: [] }, - { id: "sub-2", unreadCount: 5, tags: [] }, - { id: "sub-3", unreadCount: 3, tags: [] }, - ], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-2", starred: true, type: "email" }, - { id: "entry-3", subscriptionId: "sub-3", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(9); // sub-1 - expect(cachedData.items[1].unreadCount).toBe(4); // sub-2 - expect(cachedData.items[2].unreadCount).toBe(2); // sub-3 - }); - }); }); diff --git a/tests/unit/frontend/hooks/useEntryMutations.test.ts b/tests/unit/frontend/hooks/useEntryMutations.test.ts index 5d017ddb..e5c0bde4 100644 --- a/tests/unit/frontend/hooks/useEntryMutations.test.ts +++ b/tests/unit/frontend/hooks/useEntryMutations.test.ts @@ -166,8 +166,7 @@ describe("useEntryMutations", () => { describe("useEntryMutations behavior", () => { /** * Note: Full integration testing of useEntryMutations requires a running - * tRPC client which needs significant mocking infrastructure. The cache - * operations themselves are tested in cache/operations.test.ts. + * tRPC client which needs significant mocking infrastructure. * * These tests document the expected behavior: * @@ -185,10 +184,10 @@ describe("useEntryMutations behavior", () => { * 10. isStarPending is true when star or unstar mutation is pending * * On success: - * - markRead calls handleEntriesMarkedRead with returned entries + * - markRead updates entries collection + entries.get cache + counts * - markAllRead invalidates entries.list, subscriptions.list, tags.list, and starred count - * - star calls handleEntryStarred - * - unstar calls handleEntryUnstarred + * - star updates entries collection + entries.get cache + counts + * - unstar updates entries collection + entries.get cache + counts * * On error: * - All mutations show a toast error message @@ -198,7 +197,7 @@ describe("useEntryMutations behavior", () => { // markRead should: // 1. Accept array of IDs and boolean read status // 2. Call entries.markRead mutation - // 3. On success: call handleEntriesMarkedRead with response entries + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to update read status" expect(true).toBe(true); }); @@ -224,7 +223,7 @@ describe("useEntryMutations behavior", () => { // star should: // 1. Accept entryId // 2. Call entries.star mutation with { id: entryId } - // 3. On success: call handleEntryStarred with entry id and read status + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to star entry" expect(true).toBe(true); }); @@ -233,7 +232,7 @@ describe("useEntryMutations behavior", () => { // unstar should: // 1. Accept entryId // 2. Call entries.unstar mutation with { id: entryId } - // 3. On success: call handleEntryUnstarred with entry id and read status + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to unstar entry" expect(true).toBe(true); }); @@ -259,20 +258,18 @@ describe("useEntryMutations behavior", () => { describe("cache integration", () => { /** - * The cache operations called by useEntryMutations are tested in + * Entry state (read, starred, score) is managed by TanStack DB collections. + * Subscription lifecycle and count operations are tested in * tests/unit/frontend/cache/operations.test.ts * * This documents which operations are called by which mutations: */ - it("markRead mutation uses handleEntriesMarkedRead", () => { - // handleEntriesMarkedRead updates: - // - entries.get cache for each entry - // - subscriptions.list unread counts - // - tags.list unread counts - // - entries.count for starred entries - // - entries.count for saved entries (if type is saved) - // - entries.list (removes entries if filtering by unread) + it("markRead mutation updates entries collection and counts", () => { + // markRead updates: + // - entries collection via updateEntryReadInCollection (optimistic) + // - entries.get cache for detail view + // - subscription/tag/entries counts via setBulkCounts on success expect(true).toBe(true); }); @@ -285,19 +282,19 @@ describe("cache integration", () => { expect(true).toBe(true); }); - it("star mutation uses handleEntryStarred", () => { - // handleEntryStarred updates: + it("star mutation updates entries collection and counts", () => { + // star updates: + // - entries collection via updateEntryStarredInCollection (optimistic) // - entries.get cache to set starred: true - // - entries.count with starredOnly: true (total +1, unread +1 if entry is unread) - // - entries.list to add entry to starred list + // - subscription/tag/entries counts via setCounts on success expect(true).toBe(true); }); - it("unstar mutation uses handleEntryUnstarred", () => { - // handleEntryUnstarred updates: + it("unstar mutation updates entries collection and counts", () => { + // unstar updates: + // - entries collection via updateEntryStarredInCollection (optimistic) // - entries.get cache to set starred: false - // - entries.count with starredOnly: true (total -1, unread -1 if entry is unread) - // - entries.list to remove entry from starred list + // - subscription/tag/entries counts via setCounts on success expect(true).toBe(true); }); }); From d3d83a146e66450915eaa2906a9b2254e84702aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 01:28:00 +0000 Subject: [PATCH 06/31] Migrate sidebar counts to TanStack DB, remove React Query dual-writes (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All count/subscription/tag state updates now flow through TanStack DB collections only. React Query is only used for entries.get (detail view) and entries.list (pagination). This eliminates the dual-write pattern where every mutation wrote to both React Query caches and collections. Changes: - SidebarNav: replace entries.count.useSuspenseQuery with useLiveQuery(counts) - provider.tsx: seed "all"/"starred"/"saved" counts from SSR cache - writes.ts: add setEntriesCountInCollection, adjustEntriesCountInCollection, setUncategorizedUnreadInCollection - operations.ts: rewrite to collection-only writes (718→263 LOC) - count-cache.ts: strip to calculateTagDeltasFromSubscriptions only (468→51 LOC) - event-handlers.ts: remove applySyncTagChanges/removeSyncTags, remove queryClient param - Remove useQueryClient from useEntryMutations, useRealtimeUpdates, SubscribeContent, BrokenFeedsSettingsContent Net: -999 LOC of React Query cache manipulation code Co-Authored-By: Claude Opus 4.6 --- src/components/layout/Sidebar.tsx | 2 +- src/components/layout/SidebarNav.tsx | 72 +- .../pages/BrokenFeedsSettingsContent.tsx | 4 +- src/components/subscribe/SubscribeContent.tsx | 4 +- src/lib/cache/count-cache.ts | 432 +----------- src/lib/cache/event-handlers.ts | 33 +- src/lib/cache/operations.ts | 625 +++--------------- src/lib/collections/writes.ts | 68 +- src/lib/hooks/useEntryMutations.ts | 6 +- src/lib/hooks/useRealtimeUpdates.ts | 14 +- src/lib/trpc/provider.tsx | 22 +- tests/unit/frontend/cache/operations.test.ts | 364 +++------- 12 files changed, 318 insertions(+), 1328 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b0985ffe..9e36b590 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -47,7 +47,7 @@ export function Sidebar({ onClose }: SidebarProps) { // Close dialog immediately for responsive feel setUnsubscribeTarget(null); // Use centralized cache operation for optimistic removal (dual-write to collections) - handleSubscriptionDeleted(utils, variables.id, queryClient, collections); + handleSubscriptionDeleted(utils, variables.id, collections); }, onError: () => { toast.error("Failed to unsubscribe from feed"); diff --git a/src/components/layout/SidebarNav.tsx b/src/components/layout/SidebarNav.tsx index 3158cfe0..33732841 100644 --- a/src/components/layout/SidebarNav.tsx +++ b/src/components/layout/SidebarNav.tsx @@ -1,17 +1,18 @@ /** * SidebarNav Component * - * Navigation section of the sidebar with streaming unread counts. - * Each count suspends independently, allowing the nav structure to render immediately. + * Navigation section of the sidebar with reactive unread counts. + * Counts are read from the TanStack DB counts collection, which is + * seeded from SSR-prefetched data and updated by mutations/SSE events. */ "use client"; -import { Suspense } from "react"; +import { useMemo } from "react"; import { usePathname } from "next/navigation"; -import { trpc } from "@/lib/trpc/client"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCollections } from "@/lib/collections/context"; import { NavLink } from "@/components/ui/nav-link"; -import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; interface SidebarNavProps { onNavigate: () => void; @@ -28,41 +29,20 @@ function CountBadge({ count }: { count: number }) { } /** - * Suspending component that fetches and displays a single count. - * Returns null when count is 0 (no badge shown). - */ -function AllItemsCount() { - const [data] = trpc.entries.count.useSuspenseQuery({}); - return ; -} - -function StarredCount() { - const [data] = trpc.entries.count.useSuspenseQuery({ starredOnly: true }); - return ; -} - -function SavedCount() { - const [data] = trpc.entries.count.useSuspenseQuery({ type: "saved" }); - return ; -} - -/** - * Wraps a count component with ErrorBoundary and Suspense. - * Shows nothing during loading or on error (graceful degradation). - */ -function SuspenseCount({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** - * Main navigation links with independently streaming unread counts. + * Main navigation links with reactive unread counts from the counts collection. */ export function SidebarNav({ onNavigate }: SidebarNavProps) { const pathname = usePathname(); + const { counts: countsCollection } = useCollections(); + const { data: allCounts } = useLiveQuery(countsCollection); + + const countMap = useMemo(() => { + const map = new Map(); + for (const record of allCounts) { + map.set(record.id, record.unread); + } + return map; + }, [allCounts]); const isActiveLink = (href: string) => pathname === href; @@ -71,11 +51,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > All Items @@ -84,11 +60,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > Starred @@ -97,11 +69,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > Saved diff --git a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx index d758cf8e..814e7de5 100644 --- a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx +++ b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx @@ -8,7 +8,6 @@ "use client"; import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; @@ -39,7 +38,6 @@ interface BrokenFeed { // ============================================================================ export default function BrokenFeedsSettingsContent() { - const queryClient = useQueryClient(); const collections = useCollections(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; @@ -52,7 +50,7 @@ export default function BrokenFeedsSettingsContent() { const unsubscribeMutation = trpc.subscriptions.delete.useMutation({ onMutate: (variables) => { // Use centralized cache operation for optimistic removal - handleSubscriptionDeleted(utils, variables.id, queryClient, collections); + handleSubscriptionDeleted(utils, variables.id, collections); }, onSuccess: () => { utils.brokenFeeds.list.invalidate(); diff --git a/src/components/subscribe/SubscribeContent.tsx b/src/components/subscribe/SubscribeContent.tsx index eb14cfbd..c6c51902 100644 --- a/src/components/subscribe/SubscribeContent.tsx +++ b/src/components/subscribe/SubscribeContent.tsx @@ -9,7 +9,6 @@ "use client"; import { useState, useCallback } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; @@ -37,7 +36,6 @@ type Step = "input" | "discovery" | "preview"; // ============================================================================ export function SubscribeContent() { - const queryClient = useQueryClient(); const collections = useCollections(); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState(); @@ -70,7 +68,7 @@ export function SubscribeContent() { const subscribeMutation = trpc.subscriptions.create.useMutation({ onSuccess: (data) => { // Use centralized cache operation for consistent behavior with SSE events - handleSubscriptionCreated(utils, data, queryClient, collections); + handleSubscriptionCreated(utils, data, collections); clientPush("/all"); }, onError: () => { diff --git a/src/lib/cache/count-cache.ts b/src/lib/cache/count-cache.ts index 4cdd4f2b..b1968d16 100644 --- a/src/lib/cache/count-cache.ts +++ b/src/lib/cache/count-cache.ts @@ -1,330 +1,12 @@ /** * Count Cache Helpers * - * Functions for updating unread counts on subscriptions and tags. - * These work with the subscriptions.list and tags.list caches. + * Utility functions for computing tag deltas from subscription deltas. + * Uses TanStack DB collections for subscription lookups. */ -import type { QueryClient } from "@tanstack/react-query"; -import type { TRPCClientUtils } from "@/lib/trpc/client"; import type { Collections } from "@/lib/collections"; -/** - * Full subscription type as returned by subscriptions.list/get. - * Inferred from the tRPC client utils to stay in sync with the router schema. - */ -export type CachedSubscription = NonNullable< - ReturnType ->["items"][number]; - -/** - * Page structure in subscription infinite query cache. - */ -interface CachedSubscriptionPage { - items: CachedSubscription[]; - nextCursor?: string; -} - -/** - * Regular query data structure for subscriptions. - */ -interface SubscriptionListData { - items: CachedSubscription[]; - nextCursor?: string; -} - -/** - * Infinite query data structure for subscriptions. - */ -interface SubscriptionInfiniteData { - pages: CachedSubscriptionPage[]; - pageParams: unknown[]; -} - -/** - * Applies subscription unread count deltas to a list of subscriptions. - */ -function applySubscriptionDeltas( - items: T[], - subscriptionDeltas: Map -): T[] { - return items.map((sub) => { - const delta = subscriptionDeltas.get(sub.id); - if (delta !== undefined) { - return { - ...sub, - unreadCount: Math.max(0, sub.unreadCount + delta), - }; - } - return sub; - }); -} - -/** - * Collects all cached subscriptions from both the unparameterized query - * and all per-tag infinite queries into a single map. - * - * Subscriptions may only exist in one cache or the other depending on - * which views have been loaded (entry view vs sidebar tag views). - */ -function getAllCachedSubscriptions( - utils: TRPCClientUtils, - queryClient?: QueryClient -): Map { - const subscriptionMap = new Map(); - - // Check the unparameterized query (used by UnifiedEntriesContent/EntryContent) - const subscriptionsData = utils.subscriptions.list.getData(); - if (subscriptionsData) { - for (const s of subscriptionsData.items) { - subscriptionMap.set(s.id, s); - } - } - - // Check per-tag infinite queries (used by TagSubscriptionList in sidebar) - if (queryClient) { - forEachCachedSubscription(queryClient, (s) => { - if (!subscriptionMap.has(s.id)) { - subscriptionMap.set(s.id, s); - } - }); - } - - return subscriptionMap; -} - -/** - * Iterates over all subscriptions in cached subscription list queries. - * Handles both regular queries and infinite queries (used by sidebar). - */ -function forEachCachedSubscription( - queryClient: QueryClient, - callback: (subscription: CachedSubscription) => void -): void { - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - for (const [, data] of queries) { - if (!data) continue; - - // Check if it's infinite query format (has pages array) - if ("pages" in data && Array.isArray(data.pages)) { - for (const page of data.pages) { - if (page?.items) { - for (const s of page.items) { - callback(s); - } - } - } - } - // Regular query format (has items directly) - else if ("items" in data && Array.isArray(data.items)) { - for (const s of data.items) { - callback(s); - } - } - } -} - -/** - * Finds a single subscription by ID across all subscription caches. - * - * Checks both the unparameterized subscriptions.list query (populated by entry pages) - * and per-tag infinite queries (populated by sidebar tag sections). - * - * Useful for providing placeholder data when navigating to a subscription page, - * since the subscription may be cached from the sidebar but not from the entry page query. - */ -export function findCachedSubscription( - utils: TRPCClientUtils, - queryClient: QueryClient, - subscriptionId: string, - collections?: Collections | null -): CachedSubscription | undefined { - // Check TanStack DB collection first (synchronous, has all data) - if (collections) { - const sub = collections.subscriptions.get(subscriptionId); - if (sub) return sub as CachedSubscription; - } - - // Check the unparameterized query first (cheaper lookup) - const listData = utils.subscriptions.list.getData(); - if (listData) { - const found = listData.items.find((s) => s.id === subscriptionId); - if (found) return found; - } - - // Check per-tag infinite queries - let found: CachedSubscription | undefined; - forEachCachedSubscription(queryClient, (s) => { - if (!found && s.id === subscriptionId) { - found = s; - } - }); - return found; -} - -/** - * Adjusts unread counts for subscriptions in the cache. - * Updates both the unparameterized query (used by UnifiedEntriesContent/EntryContent) - * and all per-tag infinite queries (used by TagSubscriptionList in sidebar). - * - * @param utils - tRPC utils for cache access - * @param subscriptionDeltas - Map of subscriptionId -> count change (+1 for unread, -1 for read) - * @param queryClient - React Query client for updating infinite query caches - */ -export function adjustSubscriptionUnreadCounts( - utils: TRPCClientUtils, - subscriptionDeltas: Map, - queryClient?: QueryClient -): void { - if (subscriptionDeltas.size === 0) return; - - // Update the unparameterized query (used by UnifiedEntriesContent/EntryContent) - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: applySubscriptionDeltas(oldData.items, subscriptionDeltas), - }; - }); - - // Update all per-tag infinite queries (used by TagSubscriptionList in sidebar) - // These have query keys like [["subscriptions", "list"], { input: {...}, type: "infinite" }] - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - for (const [queryKey, data] of infiniteQueries) { - // Only update infinite queries (they have pages array) - if (!data?.pages) continue; - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: applySubscriptionDeltas(page.items, subscriptionDeltas), - })), - }); - } - } -} - -/** - * Adjusts unread counts for tags in the cache, including the uncategorized section. - * - * @param utils - tRPC utils for cache access - * @param tagDeltas - Map of tagId -> count change (+1 for unread, -1 for read) - * @param uncategorizedDelta - Count change for uncategorized subscriptions - */ -export function adjustTagUnreadCounts( - utils: TRPCClientUtils, - tagDeltas: Map, - uncategorizedDelta: number = 0 -): void { - if (tagDeltas.size === 0 && uncategorizedDelta === 0) return; - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: oldData.items.map((tag) => { - const delta = tagDeltas.get(tag.id); - if (delta !== undefined) { - return { - ...tag, - unreadCount: Math.max(0, tag.unreadCount + delta), - }; - } - return tag; - }), - uncategorized: - uncategorizedDelta !== 0 - ? { - ...oldData.uncategorized, - unreadCount: Math.max(0, oldData.uncategorized.unreadCount + uncategorizedDelta), - } - : oldData.uncategorized, - }; - }); -} - -/** - * Adjusts the entries.count cache values. - * - * @param utils - tRPC utils for cache access - * @param filters - The filter params to target (e.g., { starredOnly: true }) - * @param unreadDelta - Change to unread count - * @param totalDelta - Change to total count (default: 0) - */ -export function adjustEntriesCount( - utils: TRPCClientUtils, - filters: Parameters[0], - unreadDelta: number, - totalDelta: number = 0 -): void { - utils.entries.count.setData(filters, (oldData) => { - if (!oldData) return oldData; - return { - total: Math.max(0, oldData.total + totalDelta), - unread: Math.max(0, oldData.unread + unreadDelta), - }; - }); -} - -/** - * Adds a new subscription to the subscriptions.list cache. - * Used when subscription_created SSE event arrives. - * - * @param utils - tRPC utils for cache access - * @param subscription - The new subscription to add - */ -export function addSubscriptionToCache( - utils: TRPCClientUtils, - subscription: CachedSubscription & { - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - originalTitle: string | null; - description: string | null; - siteUrl: string | null; - subscribedAt: Date; - fetchFullContent: boolean; - } -): void { - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - // Check for duplicates (SSE race condition) - if (oldData.items.some((s) => s.id === subscription.id)) { - return oldData; - } - - return { - ...oldData, - items: [...oldData.items, subscription], - }; - }); -} - -/** - * Removes a subscription from the subscriptions.list cache. - * Used for optimistic updates when unsubscribing. - * - * @param utils - tRPC utils for cache access - * @param subscriptionId - ID of the subscription to remove - */ -export function removeSubscriptionFromCache(utils: TRPCClientUtils, subscriptionId: string): void { - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.filter((s) => s.id !== subscriptionId), - }; - }); -} - /** * Result of calculating tag deltas from subscription deltas. */ @@ -335,28 +17,24 @@ export interface TagDeltaResult { /** * Calculates tag deltas from subscription deltas. - * Uses the cached subscription data to look up which tags each subscription has. - * Also calculates the delta for uncategorized subscriptions (those with no tags). + * Looks up subscription data from the TanStack DB collection to determine + * which tags each subscription belongs to. * - * @param utils - tRPC utils for cache access * @param subscriptionDeltas - Map of subscriptionId -> count change - * @param queryClient - React Query client for searching infinite query caches + * @param collections - TanStack DB collections for subscription lookups * @returns Tag deltas and uncategorized delta */ export function calculateTagDeltasFromSubscriptions( - utils: TRPCClientUtils, subscriptionDeltas: Map, - queryClient?: QueryClient + collections: Collections | null ): TagDeltaResult { const tagDeltas = new Map(); let uncategorizedDelta = 0; - const subscriptionMap = getAllCachedSubscriptions(utils, queryClient); - if (subscriptionMap.size === 0) return { tagDeltas, uncategorizedDelta }; + if (!collections) return { tagDeltas, uncategorizedDelta }; - // Calculate tag deltas and uncategorized delta for (const [subscriptionId, delta] of subscriptionDeltas) { - const subscription = subscriptionMap.get(subscriptionId); + const subscription = collections.subscriptions.get(subscriptionId); if (subscription) { if (subscription.tags.length === 0) { uncategorizedDelta += delta; @@ -371,97 +49,3 @@ export function calculateTagDeltasFromSubscriptions( return { tagDeltas, uncategorizedDelta }; } - -// ============================================================================ -// Tag Cache Direct Updates (for sync) -// ============================================================================ - -/** - * Tag data for sync operations. - */ -export interface SyncTag { - id: string; - name: string; - color: string | null; -} - -/** - * Updates or adds tags in the tags.list cache based on sync data. - * Does not invalidate - directly applies changes. - * - * @param utils - tRPC utils for cache access - * @param createdTags - Tags that are newly created - * @param updatedTags - Tags that have been updated - */ -export function applySyncTagChanges( - utils: TRPCClientUtils, - createdTags: SyncTag[], - updatedTags: SyncTag[] -): void { - if (createdTags.length === 0 && updatedTags.length === 0) return; - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - // Create a map for efficient lookups - const updatedTagsMap = new Map(updatedTags.map((t) => [t.id, t])); - - // Update existing tags - let items = oldData.items.map((tag) => { - const update = updatedTagsMap.get(tag.id); - if (update) { - return { - ...tag, - name: update.name, - color: update.color, - }; - } - return tag; - }); - - // Add newly created tags (with default counts) - for (const newTag of createdTags) { - // Check for duplicates (race condition) - if (!items.some((t) => t.id === newTag.id)) { - items.push({ - id: newTag.id, - name: newTag.name, - color: newTag.color, - feedCount: 0, - unreadCount: 0, - createdAt: new Date(), // Approximate, doesn't need to be exact - }); - } - } - - // Sort by name (matching server behavior) - items = items.sort((a, b) => a.name.localeCompare(b.name)); - - return { - ...oldData, - items, - }; - }); -} - -/** - * Removes tags from the tags.list cache based on sync data. - * Does not invalidate - directly removes from cache. - * - * @param utils - tRPC utils for cache access - * @param removedTagIds - IDs of tags to remove - */ -export function removeSyncTags(utils: TRPCClientUtils, removedTagIds: string[]): void { - if (removedTagIds.length === 0) return; - - const removedSet = new Set(removedTagIds); - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: oldData.items.filter((tag) => !removedSet.has(tag.id)), - }; - }); -} diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 9317d9e1..347f5f44 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -4,13 +4,14 @@ * Provides unified event handling for both SSE and sync endpoints. * Both SSE real-time events and sync polling use the same event types, * so we can share the cache update logic between them. + * + * State updates flow through TanStack DB collections for sidebar/list views. + * React Query entries.get is still updated for the detail view. */ -import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; import type { Collections } from "@/lib/collections"; import { handleSubscriptionCreated, handleSubscriptionDeleted, handleNewEntry } from "./operations"; -import { applySyncTagChanges, removeSyncTags } from "./count-cache"; import { addTagToCollection, updateTagInCollection, @@ -172,26 +173,25 @@ export type SyncEvent = // ============================================================================ /** - * Handles a sync event by updating the appropriate caches. + * Handles a sync event by updating the appropriate caches and collections. * * This is the unified event handler used by both SSE and sync endpoints. - * It dispatches to the appropriate cache update functions based on event type. + * It dispatches to the appropriate update functions based on event type. * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache updates + * @param utils - tRPC utils for entries.get cache and invalidation * @param event - The event to handle + * @param collections - TanStack DB collections for state updates */ export function handleSyncEvent( utils: TRPCClientUtils, - queryClient: QueryClient, event: SyncEvent, collections?: Collections | null ): void { switch (event.type) { case "new_entry": - // Update unread counts without invalidating entries.list + // Update unread counts in collections if (event.feedType) { - handleNewEntry(utils, event.subscriptionId, event.feedType, queryClient, collections); + handleNewEntry(utils, event.subscriptionId, event.feedType, collections); } break; @@ -254,7 +254,6 @@ export function handleSyncEvent( tags: subscription.tags, fetchFullContent: false, }, - queryClient, collections ); break; @@ -263,31 +262,23 @@ export function handleSyncEvent( case "subscription_deleted": // Check if already removed (optimistic update from same tab) { - // Check both the old cache and the TanStack DB collection - const currentData = utils.subscriptions.list.getData(); - const alreadyRemovedFromCache = - currentData && !currentData.items.some((s) => s.id === event.subscriptionId); - const alreadyRemovedFromCollection = - collections && !collections.subscriptions.has(event.subscriptionId); + const alreadyRemoved = collections && !collections.subscriptions.has(event.subscriptionId); - if (!alreadyRemovedFromCache || !alreadyRemovedFromCollection) { - handleSubscriptionDeleted(utils, event.subscriptionId, queryClient, collections); + if (!alreadyRemoved) { + handleSubscriptionDeleted(utils, event.subscriptionId, collections); } } break; case "tag_created": - applySyncTagChanges(utils, [event.tag], []); addTagToCollection(collections ?? null, event.tag); break; case "tag_updated": - applySyncTagChanges(utils, [], [event.tag]); updateTagInCollection(collections ?? null, event.tag); break; case "tag_deleted": - removeSyncTags(utils, [event.tagId]); removeTagFromCollection(collections ?? null, event.tagId); break; diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index 668ef3cd..cd1ee889 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -1,23 +1,14 @@ /** * Cache Operations * - * Higher-level functions for subscription/count cache updates. - * Entry state (read, starred, score) is managed by TanStack DB collections. - * These operations handle subscription lifecycle and count updates. + * Higher-level functions for subscription lifecycle and count updates. + * All state updates flow through TanStack DB collections. + * React Query is only used for entries.list invalidation (pagination). */ -import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; import type { Collections, Subscription } from "@/lib/collections"; -import { - adjustSubscriptionUnreadCounts, - adjustTagUnreadCounts, - adjustEntriesCount, - calculateTagDeltasFromSubscriptions, - addSubscriptionToCache, - removeSubscriptionFromCache, - findCachedSubscription, -} from "./count-cache"; +import { calculateTagDeltasFromSubscriptions } from "./count-cache"; import { adjustSubscriptionUnreadInCollection, adjustTagUnreadInCollection, @@ -29,6 +20,9 @@ import { setBulkSubscriptionUnreadInCollection, adjustTagFeedCountInCollection, adjustUncategorizedFeedCountInCollection, + setEntriesCountInCollection, + adjustEntriesCountInCollection, + setUncategorizedUnreadInCollection, } from "@/lib/collections/writes"; /** @@ -48,222 +42,23 @@ export interface SubscriptionData { fetchFullContent: boolean; } -// ============================================================================ -// Private Helpers (shared logic to avoid duplication) -// ============================================================================ - -/** - * Updates subscription and tag unread counts based on deltas. - * Shared logic used by multiple operations to ensure consistent behavior. - * - * @param utils - tRPC utils for cache access - * @param subscriptionDeltas - Map of subscriptionId -> unread count delta - * @param queryClient - React Query client for updating infinite query caches - */ -function updateSubscriptionAndTagCounts( - utils: TRPCClientUtils, - subscriptionDeltas: Map, - queryClient?: QueryClient, - collections?: Collections | null -): void { - adjustSubscriptionUnreadCounts(utils, subscriptionDeltas, queryClient); - const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( - utils, - subscriptionDeltas, - queryClient - ); - adjustTagUnreadCounts(utils, tagDeltas, uncategorizedDelta); - - // Also update TanStack DB collections (dual-write during migration) - adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); - adjustTagUnreadInCollection(collections ?? null, tagDeltas); - adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); -} - -/** - * Query key input type for subscriptions.list. - */ -interface SubscriptionListInput { - tagId?: string; - uncategorized?: boolean; - query?: string; - unreadOnly?: boolean; - cursor?: string; - limit?: number; -} - -/** - * Invalidates subscription list queries for specific tags. - * More targeted than invalidating all subscription lists. - * - * @param queryClient - React Query client - * @param tagIds - Tag IDs to invalidate queries for - * @param includeUncategorized - Whether to also invalidate the uncategorized query - */ -function invalidateSubscriptionListsForTags( - queryClient: QueryClient, - tagIds: string[], - includeUncategorized: boolean -): void { - const tagIdSet = new Set(tagIds); - - // Get all subscription list queries - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey] of queries) { - // Query key structure: [["subscriptions", "list"], { input: {...}, type: "query"|"infinite" }] - const keyData = queryKey[1] as { input?: SubscriptionListInput; type?: string } | undefined; - const input = keyData?.input; - - // Always invalidate the unparameterized query (used by entry content pages) - if (!input) { - queryClient.invalidateQueries({ queryKey }); - continue; - } - - // Invalidate if this query is for one of the affected tags - if (input.tagId && tagIdSet.has(input.tagId)) { - queryClient.invalidateQueries({ queryKey }); - continue; - } - - // Invalidate uncategorized query if needed - if (includeUncategorized && input.uncategorized === true) { - queryClient.invalidateQueries({ queryKey }); - } - } -} - -/** - * Page structure in subscription infinite query cache. - */ -interface CachedSubscriptionPage { - items: Array<{ id: string; [key: string]: unknown }>; - nextCursor?: string; -} - -/** - * Infinite query data structure for subscriptions. - */ -interface SubscriptionInfiniteData { - pages: CachedSubscriptionPage[]; - pageParams: unknown[]; -} - -/** - * Removes a subscription from all infinite query caches. - * Used when unsubscribing to immediately remove from sidebar lists. - * - * @param queryClient - React Query client - * @param subscriptionId - ID of the subscription to remove - */ -function removeSubscriptionFromInfiniteQueries( - queryClient: QueryClient, - subscriptionId: string -): void { - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - // Only update infinite queries (they have pages array) - if (!data?.pages) continue; - - // Check if any page contains this subscription - const hasSubscription = data.pages.some((page) => - page.items.some((s) => s.id === subscriptionId) - ); - - if (hasSubscription) { - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.filter((s) => s.id !== subscriptionId), - })), - }); - } - } -} - /** * Handles a new subscription being created. * - * Updates: - * - subscriptions.list (add subscription to unparameterized cache) - * - subscriptions.list per-tag infinite queries (only affected tags, or uncategorized if no tags) - * - tags.list (direct update of feedCount and unreadCount) - * - entries.count (direct update) - * - * @param utils - tRPC utils for cache access - * @param subscription - The new subscription data - * @param queryClient - React Query client for targeted invalidations + * Updates TanStack DB collections: + * - subscriptions collection (add subscription) + * - tags/uncategorized counts (feedCount + unreadCount) + * - entries counts ("all") */ export function handleSubscriptionCreated( utils: TRPCClientUtils, subscription: SubscriptionData, - queryClient?: QueryClient, collections?: Collections | null ): void { - addSubscriptionToCache(utils, subscription); - - // Also add to TanStack DB collection (dual-write during migration) - // Cast to Subscription type - the SubscriptionData shape matches what the collection expects + // Add to TanStack DB subscriptions collection addSubscriptionToCollection(collections ?? null, subscription as unknown as Subscription); - // Invalidate only the affected subscription list queries - // - The unparameterized query (no input) for entry content pages - // - Per-tag queries for tags the subscription belongs to - // - Uncategorized query if subscription has no tags - if (queryClient) { - invalidateSubscriptionListsForTags( - queryClient, - subscription.tags.map((t) => t.id), - subscription.tags.length === 0 - ); - } else { - // Fallback: invalidate all subscription lists - utils.subscriptions.list.invalidate(); - } - - // Directly update tags.list with feedCount and unreadCount changes - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - if (subscription.tags.length === 0) { - // Uncategorized subscription - return { - ...oldData, - uncategorized: { - feedCount: oldData.uncategorized.feedCount + 1, - unreadCount: oldData.uncategorized.unreadCount + subscription.unreadCount, - }, - }; - } - - // Update feedCount and unreadCount for each tag the subscription belongs to - const tagIds = new Set(subscription.tags.map((t) => t.id)); - return { - ...oldData, - items: oldData.items.map((tag) => { - if (tagIds.has(tag.id)) { - return { - ...tag, - feedCount: tag.feedCount + 1, - unreadCount: tag.unreadCount + subscription.unreadCount, - }; - } - return tag; - }), - }; - }); - - // Directly update entries.count for All Articles - adjustEntriesCount(utils, {}, subscription.unreadCount, subscription.unreadCount); - - // Update tag/uncategorized feedCount and unreadCount in TanStack DB collections + // Update tag/uncategorized feedCount and unreadCount in collections if (subscription.tags.length === 0) { adjustUncategorizedFeedCountInCollection(collections ?? null, 1); adjustUncategorizedUnreadInCollection(collections ?? null, subscription.unreadCount); @@ -274,95 +69,36 @@ export function handleSubscriptionCreated( adjustTagUnreadInCollection(collections ?? null, tagDeltas); } } + + // Update entry counts + adjustEntriesCountInCollection( + collections ?? null, + "all", + subscription.unreadCount, + subscription.unreadCount + ); } /** * Handles a subscription being deleted. * - * Updates: - * - subscriptions.list (remove subscription from caches) - * - subscriptions.list per-tag infinite queries (only affected tags, or uncategorized) - * - entries.list (invalidated - entries may be filtered out) - * - tags.list (direct update of feedCount and unreadCount if subscription found in cache) - * - entries.count (direct update if subscription found in cache) + * Updates TanStack DB collections: + * - subscriptions collection (remove subscription) + * - tags/uncategorized counts (feedCount + unreadCount) + * - entries counts ("all") * - * @param utils - tRPC utils for cache access - * @param subscriptionId - ID of the deleted subscription - * @param queryClient - React Query client for targeted invalidations + * Also invalidates entries.list to refetch entry pages. */ export function handleSubscriptionDeleted( utils: TRPCClientUtils, subscriptionId: string, - queryClient?: QueryClient, collections?: Collections | null ): void { - // Look up subscription data before removing from cache - // This lets us do targeted updates instead of broad invalidations - // Check TanStack DB collection first (has all data, synchronous lookup) - const subscription = - collections?.subscriptions.get(subscriptionId) ?? - (queryClient ? findCachedSubscription(utils, queryClient, subscriptionId) : undefined); - - // Remove from all subscription caches - removeSubscriptionFromCache(utils, subscriptionId); - if (queryClient) { - removeSubscriptionFromInfiniteQueries(queryClient, subscriptionId); - } - - if (subscription && queryClient) { - // Targeted invalidations using subscription data - invalidateSubscriptionListsForTags( - queryClient, - subscription.tags.map((t) => t.id), - subscription.tags.length === 0 - ); - - // Directly update tags.list feedCount and unreadCount - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - if (subscription.tags.length === 0) { - // Uncategorized subscription - return { - ...oldData, - uncategorized: { - feedCount: Math.max(0, oldData.uncategorized.feedCount - 1), - unreadCount: Math.max(0, oldData.uncategorized.unreadCount - subscription.unreadCount), - }, - }; - } - - // Update feedCount and unreadCount for each tag - const tagIds = new Set(subscription.tags.map((t) => t.id)); - return { - ...oldData, - items: oldData.items.map((tag) => { - if (tagIds.has(tag.id)) { - return { - ...tag, - feedCount: Math.max(0, tag.feedCount - 1), - unreadCount: Math.max(0, tag.unreadCount - subscription.unreadCount), - }; - } - return tag; - }), - }; - }); - - // Directly update entries.count for All Articles - adjustEntriesCount(utils, {}, -subscription.unreadCount, -subscription.unreadCount); - } else { - // Fallback: invalidate broadly when we don't have subscription data - utils.subscriptions.list.invalidate(); - utils.tags.list.invalidate(); - utils.entries.count.invalidate(); - } - - // Always invalidate entries.list - entries from this subscription should be filtered out - utils.entries.list.invalidate(); + // Look up subscription data before removing from collection + const subscription = collections?.subscriptions.get(subscriptionId); - // Remove from TanStack DB collection and adjust tag/uncategorized counts if (subscription) { + // Targeted updates using subscription data if (subscription.tags.length === 0) { adjustUncategorizedFeedCountInCollection(collections ?? null, -1); adjustUncategorizedUnreadInCollection(collections ?? null, -subscription.unreadCount); @@ -373,51 +109,58 @@ export function handleSubscriptionDeleted( adjustTagUnreadInCollection(collections ?? null, tagDeltas); } } + + // Update entry counts + adjustEntriesCountInCollection( + collections ?? null, + "all", + -subscription.unreadCount, + -subscription.unreadCount + ); } + + // Remove from collection removeSubscriptionFromCollection(collections ?? null, subscriptionId); + + // Invalidate entries.list - entries from this subscription should be filtered out + utils.entries.list.invalidate(); } /** * Handles a new entry being created in a subscription. * - * Updates: - * - subscriptions.list unread counts (+1) - * - tags.list unread counts (+1) - * - entries.count({ type: "saved" }) if entry is saved - * - * Does NOT invalidate entries.list - new entries appear on next navigation. - * Note: We don't have full entry data from SSE events, so we can't update - * entries.get or entries.list caches. That's OK - entries will be fetched - * when user navigates to that view (SuspendingEntryList refetches on pathname change). - * - * @param utils - tRPC utils for cache access - * @param subscriptionId - Subscription the entry belongs to - * @param feedType - Type of feed (web, email, saved) - * @param queryClient - React Query client for updating infinite query caches + * Updates TanStack DB collections: + * - subscription unread count (+1) + * - tag/uncategorized unread counts (+1) + * - entries counts ("all" +1, "saved" +1 if saved) */ export function handleNewEntry( utils: TRPCClientUtils, subscriptionId: string | null, feedType: "web" | "email" | "saved", - queryClient?: QueryClient, collections?: Collections | null ): void { - // Update subscription and tag unread counts (only for non-saved entries) + // Update subscription and tag unread counts if (subscriptionId) { - // New entries are always unread (read: false, starred: false) const subscriptionDeltas = new Map(); - subscriptionDeltas.set(subscriptionId, 1); // +1 unread + subscriptionDeltas.set(subscriptionId, 1); - // Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient, collections); - } + // Update subscription unread in collection + adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); - // Update All Articles unread count (+1 unread, +1 total) - adjustEntriesCount(utils, {}, 1, 1); + // Calculate and apply tag deltas using subscription data from collection + const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( + subscriptionDeltas, + collections ?? null + ); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); + } - // Update saved unread count if it's a saved entry + // Update entry counts + adjustEntriesCountInCollection(collections ?? null, "all", 1, 1); if (feedType === "saved") { - adjustEntriesCount(utils, { type: "saved" }, 1, 1); + adjustEntriesCountInCollection(collections ?? null, "saved", 1, 1); } } @@ -452,266 +195,58 @@ export interface BulkUnreadCounts { /** * Sets absolute counts from server response. * Used by single-entry mutations (star, unstar) that return UnreadCounts. - * - * @param utils - tRPC utils for cache access - * @param counts - Absolute counts from server - * @param queryClient - React Query client for updating infinite query caches */ -export function setCounts( - utils: TRPCClientUtils, - counts: UnreadCounts, - queryClient?: QueryClient, - collections?: Collections | null -): void { - // Set global counts - utils.entries.count.setData({}, counts.all); - utils.entries.count.setData({ starredOnly: true }, counts.starred); - +export function setCounts(collections: Collections | null, counts: UnreadCounts): void { + // Set global counts in collection + setEntriesCountInCollection(collections, "all", counts.all.total, counts.all.unread); + setEntriesCountInCollection(collections, "starred", counts.starred.total, counts.starred.unread); if (counts.saved) { - utils.entries.count.setData({ type: "saved" }, counts.saved); + setEntriesCountInCollection(collections, "saved", counts.saved.total, counts.saved.unread); } // Set subscription unread count if (counts.subscription) { - setSubscriptionUnreadCount( - utils, + setSubscriptionUnreadInCollection( + collections, counts.subscription.id, - counts.subscription.unread, - queryClient + counts.subscription.unread ); } // Set tag unread counts if (counts.tags) { for (const tag of counts.tags) { - setTagUnreadCount(utils, tag.id, tag.unread); + setTagUnreadInCollection(collections, tag.id, tag.unread); } } // Set uncategorized count if (counts.uncategorized) { - setUncategorizedUnreadCount(utils, counts.uncategorized.unread); - } - - // Also update TanStack DB collections (dual-write during migration) - if (counts.subscription) { - setSubscriptionUnreadInCollection( - collections ?? null, - counts.subscription.id, - counts.subscription.unread - ); - } - if (counts.tags) { - for (const tag of counts.tags) { - setTagUnreadInCollection(collections ?? null, tag.id, tag.unread); - } + setUncategorizedUnreadInCollection(collections, counts.uncategorized.unread); } } /** * Sets absolute counts from bulk mutation response. * Used by markRead mutation that returns BulkUnreadCounts. - * - * @param utils - tRPC utils for cache access - * @param counts - Absolute counts from server - * @param queryClient - React Query client for updating infinite query caches */ -export function setBulkCounts( - utils: TRPCClientUtils, - counts: BulkUnreadCounts, - queryClient?: QueryClient, - collections?: Collections | null -): void { - // Set global counts - utils.entries.count.setData({}, counts.all); - utils.entries.count.setData({ starredOnly: true }, counts.starred); - utils.entries.count.setData({ type: "saved" }, counts.saved); +export function setBulkCounts(collections: Collections | null, counts: BulkUnreadCounts): void { + // Set global counts in collection + setEntriesCountInCollection(collections, "all", counts.all.total, counts.all.unread); + setEntriesCountInCollection(collections, "starred", counts.starred.total, counts.starred.unread); + setEntriesCountInCollection(collections, "saved", counts.saved.total, counts.saved.unread); - // Build subscription updates map for efficient batch update + // Set subscription unread counts const subscriptionUpdates = new Map(counts.subscriptions.map((s) => [s.id, s.unread])); - - // Build set of affected tag IDs to only update those caches - const affectedTagIds = new Set(counts.tags.map((t) => t.id)); - - // Batch update all subscription unread counts - setBulkSubscriptionUnreadCounts( - utils, - subscriptionUpdates, - affectedTagIds, - counts.uncategorized !== undefined, - queryClient - ); + setBulkSubscriptionUnreadInCollection(collections, subscriptionUpdates); // Set tag unread counts for (const tag of counts.tags) { - setTagUnreadCount(utils, tag.id, tag.unread); + setTagUnreadInCollection(collections, tag.id, tag.unread); } // Set uncategorized count if (counts.uncategorized) { - setUncategorizedUnreadCount(utils, counts.uncategorized.unread); - } - - // Also update TanStack DB collections (dual-write during migration) - setBulkSubscriptionUnreadInCollection(collections ?? null, subscriptionUpdates); - for (const tag of counts.tags) { - setTagUnreadInCollection(collections ?? null, tag.id, tag.unread); + setUncategorizedUnreadInCollection(collections, counts.uncategorized.unread); } } - -/** - * Sets unread counts for multiple subscriptions, only updating affected tag caches. - * - * Instead of scanning ALL cached tag queries for each subscription, this function - * uses the known affected tag IDs to only iterate through relevant caches. - * - * @param utils - tRPC utils for cache access - * @param subscriptionUpdates - Map of subscriptionId -> new unread count - * @param affectedTagIds - Set of tag IDs that were affected (only these caches need updating) - * @param hasUncategorized - Whether uncategorized subscriptions were affected - * @param queryClient - React Query client for updating infinite query caches - */ -function setBulkSubscriptionUnreadCounts( - utils: TRPCClientUtils, - subscriptionUpdates: Map, - affectedTagIds: Set, - hasUncategorized: boolean, - queryClient?: QueryClient -): void { - if (subscriptionUpdates.size === 0) return; - - // Update in unparameterized subscriptions.list cache - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((sub) => { - const newUnread = subscriptionUpdates.get(sub.id); - return newUnread !== undefined ? { ...sub, unreadCount: newUnread } : sub; - }), - }; - }); - - // Update only the affected per-tag infinite query caches - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData<{ - pages: Array<{ items: Array<{ id: string; unreadCount: number; [key: string]: unknown }> }>; - pageParams: unknown[]; - }>({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - // Check if this query is for an affected tag - const keyData = queryKey[1] as { input?: SubscriptionListInput } | undefined; - const input = keyData?.input; - - // Skip queries that aren't for affected tags or uncategorized - if (input) { - const isAffectedTag = input.tagId && affectedTagIds.has(input.tagId); - const isAffectedUncategorized = hasUncategorized && input.uncategorized === true; - if (!isAffectedTag && !isAffectedUncategorized) { - continue; - } - } - - // Update subscriptions in this cache - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((s) => { - const newUnread = subscriptionUpdates.get(s.id); - return newUnread !== undefined ? { ...s, unreadCount: newUnread } : s; - }), - })), - }); - } - } -} - -/** - * Sets the unread count for a specific subscription. - * Used by single-entry mutations (setCounts) where we don't know the affected tags. - */ -function setSubscriptionUnreadCount( - utils: TRPCClientUtils, - subscriptionId: string, - unread: number, - queryClient?: QueryClient -): void { - // Update in unparameterized subscriptions.list cache - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((sub) => - sub.id === subscriptionId ? { ...sub, unreadCount: unread } : sub - ), - }; - }); - - // Update in per-tag infinite query caches - // Note: For single-entry mutations, we still need to scan all caches - // since we don't have the affected tag IDs. This is acceptable because - // single-entry mutations (star/unstar) are less frequent than markRead. - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData<{ - pages: Array<{ items: Array<{ id: string; unreadCount: number; [key: string]: unknown }> }>; - pageParams: unknown[]; - }>({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - const hasSubscription = data.pages.some((page) => - page.items.some((s) => s.id === subscriptionId) - ); - - if (hasSubscription) { - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((s) => - s.id === subscriptionId ? { ...s, unreadCount: unread } : s - ), - })), - }); - } - } - } -} - -/** - * Sets the unread count for a specific tag. - */ -function setTagUnreadCount(utils: TRPCClientUtils, tagId: string, unread: number): void { - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((tag) => (tag.id === tagId ? { ...tag, unreadCount: unread } : tag)), - }; - }); -} - -/** - * Sets the uncategorized unread count. - */ -function setUncategorizedUnreadCount(utils: TRPCClientUtils, unread: number): void { - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - uncategorized: { - ...oldData.uncategorized, - unreadCount: unread, - }, - }; - }); -} diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index fe1a0129..85425e41 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -1,9 +1,8 @@ /** * Collection Write Utilities * - * Functions that update TanStack DB collections alongside the existing React Query cache. - * Each function accepts `Collections | null` and no-ops when null, enabling gradual - * migration where callers can optionally pass collections. + * Functions that update TanStack DB collections for client-side state management. + * Each function accepts `Collections | null` and no-ops when null. * * Uses `collection.utils.writeUpdate()` for query-backed collections, which writes * directly to the synced data store without triggering onInsert/onUpdate handlers @@ -258,7 +257,68 @@ export function removeTagFromCollection(collections: Collections | null, tagId: } // ============================================================================ -// Uncategorized Count Writes (via Counts Collection) +// Entry Count Writes (via Counts Collection) +// ============================================================================ + +/** + * Sets absolute entry counts from server response. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setEntriesCountInCollection( + collections: Collections | null, + key: "all" | "starred" | "saved", + total: number, + unread: number +): void { + if (!collections) return; + + if (collections.counts.has(key)) { + collections.counts.utils.writeUpdate({ id: key, total, unread }); + } else { + collections.counts.utils.writeInsert({ id: key, total, unread }); + } +} + +/** + * Adjusts entry counts by delta values. + * Used by handleNewEntry, handleSubscriptionCreated/Deleted. + */ +export function adjustEntriesCountInCollection( + collections: Collections | null, + key: "all" | "starred" | "saved", + totalDelta: number, + unreadDelta: number +): void { + if (!collections || (totalDelta === 0 && unreadDelta === 0)) return; + + const current = collections.counts.get(key); + if (current) { + collections.counts.utils.writeUpdate({ + id: key, + total: Math.max(0, current.total + totalDelta), + unread: Math.max(0, current.unread + unreadDelta), + }); + } +} + +/** + * Sets the uncategorized unread count to an absolute value. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setUncategorizedUnreadInCollection( + collections: Collections | null, + unread: number +): void { + if (!collections) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.utils.writeUpdate({ id: "uncategorized", unread }); + } +} + +// ============================================================================ +// Uncategorized Count Delta Writes (via Counts Collection) // ============================================================================ /** diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 28512ca5..b5f4e077 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -20,7 +20,6 @@ "use client"; import { useCallback, useMemo, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; @@ -152,7 +151,6 @@ interface EntryMutationTracking { */ export function useEntryMutations(): UseEntryMutationsResult { const utils = trpc.useUtils(); - const queryClient = useQueryClient(); const collections = useCollections(); // Track pending mutations per entry for timestamp-based state merging @@ -345,7 +343,7 @@ export function useEntryMutations(): UseEntryMutationsResult { } // Update counts (always apply, not dependent on timestamp) - setBulkCounts(utils, data.counts, queryClient, collections); + setBulkCounts(collections, data.counts); }, onError: (error, variables) => { @@ -450,7 +448,7 @@ export function useEntryMutations(): UseEntryMutationsResult { ); // Update counts (always apply) - setCounts(utils, data.counts, queryClient, collections); + setCounts(collections, data.counts); }, onError: (error, variables) => { diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index 705389f6..88920748 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -15,7 +15,6 @@ "use client"; import { useEffect, useRef, useCallback, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; import { handleSyncEvent, type SyncEvent } from "@/lib/cache/event-handlers"; @@ -431,7 +430,6 @@ function parseEventData(data: string): SyncEvent | null { */ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpdatesResult { const utils = trpc.useUtils(); - const queryClient = useQueryClient(); const collections = useCollections(); // Connection status state @@ -534,10 +532,10 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // Update the appropriate cursor based on event type updateCursorForEvent(data); - // Delegate to shared handler for cache updates (dual-write to collections) - handleSyncEvent(utils, queryClient, data, collections); + // Delegate to shared handler for cache/collection updates + handleSyncEvent(utils, data, collections); }, - [utils, queryClient, updateCursorForEvent, collections] + [utils, updateCursorForEvent, collections] ); /** @@ -558,10 +556,10 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda }, }); - // Process each event through the shared handler (same as SSE path, dual-write to collections) + // Process each event through the shared handler (same as SSE path) for (const event of result.events) { updateCursorForEvent(event); - handleSyncEvent(utils, queryClient, event, collections); + handleSyncEvent(utils, event, collections); } // If there are more events, schedule another sync soon @@ -575,7 +573,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda console.error("Sync failed:", error); return null; } - }, [utils, queryClient, updateCursorForEvent, collections]); + }, [utils, updateCursorForEvent, collections]); /** * Starts polling mode when SSE is unavailable. diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index d12d5299..dceef5d7 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -168,9 +168,29 @@ export function TRPCProvider({ children }: TRPCProviderProps) { links: [createBatchLink()], }); - return createCollections(queryClient, { + const cols = createCollections(queryClient, { fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), }); + + // Seed entry counts from SSR-prefetched React Query cache. + // Query key format: [["entries", "count"], { input: {...}, type: "query" }] + const countQueries = queryClient.getQueriesData<{ total: number; unread: number }>({ + queryKey: [["entries", "count"]], + }); + for (const [queryKey, data] of countQueries) { + if (!data) continue; + const keyMeta = queryKey[1] as { input?: Record } | undefined; + const input = keyMeta?.input; + if (!input || Object.keys(input).length === 0) { + cols.counts.utils.writeInsert({ id: "all", total: data.total, unread: data.unread }); + } else if (input.starredOnly === true) { + cols.counts.utils.writeInsert({ id: "starred", total: data.total, unread: data.unread }); + } else if (input.type === "saved") { + cols.counts.utils.writeInsert({ id: "saved", total: data.total, unread: data.unread }); + } + } + + return cols; }); return ( diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index 1fe7a091..a80c3502 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -1,336 +1,176 @@ /** * Unit tests for cache operations. * - * These tests document the behavior of cache operations without full mocking. - * For more comprehensive testing, see the integration tests. - * - * Note: Entry state (read, starred, score) is managed by TanStack DB collections. - * These tests cover subscription lifecycle and count update operations. + * These operations now update TanStack DB collections only (no React Query cache writes). + * Tests verify correct behavior by passing null collections (no-op mode) + * and ensuring functions don't throw. Collection state updates are tested + * via integration tests. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { createMockTrpcUtils } from "../../../utils/trpc-mock"; import { handleNewEntry, handleSubscriptionCreated, handleSubscriptionDeleted, + setCounts, + setBulkCounts, type SubscriptionData, + type UnreadCounts, + type BulkUnreadCounts, } from "@/lib/cache/operations"; -describe("handleNewEntry", () => { - let mockUtils: ReturnType; +function createSubscription(overrides: Partial = {}): SubscriptionData { + return { + id: "sub-1", + type: "web", + url: "https://example.com/feed.xml", + title: "Example Feed", + originalTitle: "Example Feed", + description: "An example feed", + siteUrl: "https://example.com", + subscribedAt: new Date("2024-01-01"), + unreadCount: 0, + tags: [], + fetchFullContent: false, + ...overrides, + }; +} - beforeEach(() => { - mockUtils = createMockTrpcUtils(); +describe("handleNewEntry", () => { + it("handles web entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + // Should not throw when collections is null + handleNewEntry(mockUtils.utils, "sub-1", "web", null); }); - it("increments subscription unread count", () => { - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const subOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(subOps.length).toBeGreaterThan(0); + it("handles saved entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, "sub-1", "saved", null); }); - it("increments saved unread count for saved entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "saved"); - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); + it("handles email entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, "sub-1", "email", null); }); - it("increments All Articles count but not saved count for web entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - // For web entries, we update All Articles count but not saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - // Only 1 operation: All Articles count (no saved count for web entries) - expect(countOps.length).toBe(1); - }); - - it("increments All Articles count but not saved count for email entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "email"); - - // Email entries update All Articles count but don't affect saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - // Only 1 operation: All Articles count (no saved count for email entries) - expect(countOps.length).toBe(1); + it("handles null subscriptionId", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, null, "web", null); }); }); describe("handleSubscriptionCreated", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - function createSubscription(overrides: Partial = {}): SubscriptionData { - return { - id: "sub-1", - type: "web", - url: "https://example.com/feed.xml", - title: "Example Feed", - originalTitle: "Example Feed", - description: "An example feed", - siteUrl: "https://example.com", - subscribedAt: new Date("2024-01-01"), - unreadCount: 0, - tags: [], - fetchFullContent: false, - ...overrides, - }; - } - - it("adds subscription to subscriptions.list cache", () => { - const subscription = createSubscription(); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); - }); - - it("directly updates tags.list cache", () => { + it("handles subscription without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription(); - handleSubscriptionCreated(mockUtils.utils, subscription); - - // Should use setData instead of invalidate for direct cache update - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "tags" && op.procedure === "list" - ); - expect(setDataOps.length).toBe(1); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); - it("adds subscription with tags to cache", () => { + it("handles subscription with tags without collections", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ tags: [ { id: "tag-1", name: "News", color: "#ff0000" }, { id: "tag-2", name: "Tech", color: null }, ], }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); - it("adds subscription with non-zero unread count", () => { + it("handles subscription with non-zero unread count", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ unreadCount: 42 }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles email subscription type", () => { - const subscription = createSubscription({ - type: "email", - url: null, - }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + const mockUtils = createMockTrpcUtils(); + const subscription = createSubscription({ type: "email", url: null }); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles saved subscription type", () => { - const subscription = createSubscription({ - type: "saved", - url: null, - }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + const mockUtils = createMockTrpcUtils(); + const subscription = createSubscription({ type: "saved", url: null }); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles subscription with null optional fields", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ title: null, description: null, siteUrl: null, }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - // Should not throw - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); - }); - - it("does not duplicate subscription if already in cache", () => { - const subscription = createSubscription(); - - // Set up cache with existing subscription - mockUtils.setCache("subscriptions", "list", undefined, { - items: [subscription], - }); - - handleSubscriptionCreated(mockUtils.utils, subscription); - - // The setData is still called, but the updater function should not add a duplicate - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: SubscriptionData[]; - }; - expect(cachedData.items.length).toBe(1); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); }); describe("handleSubscriptionDeleted", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("removes subscription from subscriptions.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBe(1); - }); - - it("invalidates entries.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); + it("invalidates entries.list", () => { + const mockUtils = createMockTrpcUtils(); + handleSubscriptionDeleted(mockUtils.utils, "sub-1", null); + // entries.list invalidation is the only React Query operation remaining const invalidateOps = mockUtils.operations.filter( (op) => op.type === "invalidate" && op.router === "entries" && op.procedure === "list" ); expect(invalidateOps.length).toBe(1); }); - it("invalidates tags.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const invalidateOps = mockUtils.operations.filter( - (op) => op.type === "invalidate" && op.router === "tags" && op.procedure === "list" - ); - expect(invalidateOps.length).toBe(1); + it("handles deletion without collections (no-op for collection writes)", () => { + const mockUtils = createMockTrpcUtils(); + handleSubscriptionDeleted(mockUtils.utils, "sub-1", null); }); +}); - it("removes subscription from cache when present", () => { - // Set up cache with subscriptions - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 5, tags: [] }, - { id: "sub-2", unreadCount: 10, tags: [] }, - ], - }); - - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; - }; - expect(cachedData.items.length).toBe(1); - expect(cachedData.items[0].id).toBe("sub-2"); - }); - - it("handles deletion of non-existent subscription gracefully", () => { - // Set up cache with different subscription - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-2", unreadCount: 10, tags: [] }], - }); - - // Should not throw - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; +describe("setCounts", () => { + it("handles counts without collections (no-op)", () => { + const counts: UnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscription: { id: "sub-1", unread: 3 }, + tags: [{ id: "tag-1", unread: 5 }], + uncategorized: { unread: 2 }, }; - expect(cachedData.items.length).toBe(1); + setCounts(null, counts); }); - it("handles deletion when cache is empty", () => { - mockUtils.setCache("subscriptions", "list", undefined, { items: [] }); - - // Should not throw - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; + it("handles counts without optional fields", () => { + const counts: UnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, }; - expect(cachedData.items.length).toBe(0); - }); -}); - -describe("cache update logic verification", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - describe("handleNewEntry cache state updates", () => { - it("increments subscription unread count", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(6); - }); - - it("increments tag unread count for subscription with tags", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { - id: "sub-1", - unreadCount: 5, - tags: [{ id: "tag-1", name: "News", color: null }], - }, - ], - }); - mockUtils.setCache("tags", "list", undefined, { - items: [{ id: "tag-1", name: "News", color: null, unreadCount: 10 }], - }); - - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const tagData = mockUtils.getCache("tags", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(tagData.items[0].unreadCount).toBe(11); - }); + setCounts(null, counts); }); }); -describe("edge cases", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); +describe("setBulkCounts", () => { + it("handles bulk counts without collections (no-op)", () => { + const counts: BulkUnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscriptions: [ + { id: "sub-1", unread: 3 }, + { id: "sub-2", unread: 7 }, + ], + tags: [{ id: "tag-1", unread: 5 }], + uncategorized: { unread: 2 }, + }; + setBulkCounts(null, counts); }); - describe("null/undefined handling", () => { - it("handleNewEntry handles when subscriptions cache is undefined", () => { - // Don't set up subscriptions cache - leave it undefined - // Should not throw - handleNewEntry(mockUtils.utils, "sub-1", "web"); - }); + it("handles bulk counts without uncategorized", () => { + const counts: BulkUnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscriptions: [], + tags: [], + }; + setBulkCounts(null, counts); }); }); From 121e1c0013f21c045bee697d140f01386996b1be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 01:44:31 +0000 Subject: [PATCH 07/31] Remove React Query count invalidations, delete entry-cache.ts (Phase 5) - Add refreshGlobalCounts helper that fetches counts via tRPC and writes to collections (replaces invalidation/subscription pattern) - Replace all entries.count.invalidate() calls (5 sites) with either refreshGlobalCounts or adjustEntriesCountInCollection - Delete entry-cache.ts (363 LOC) - replace findParentListPlaceholderData with direct collection lookup in EntryListFallback - Remove useQueryClient from Sidebar.tsx (last component import) - Update FRONTEND_STATE.md to reflect removed files After this phase, React Query is only used for: - entries.get (detail view cache with optimistic updates) - entries.list (pagination via useSuspenseInfiniteQuery) - entries.count SSR prefetch (seeds collections on first load) Co-Authored-By: Claude Opus 4.6 --- src/FRONTEND_STATE.md | 23 +- src/components/entries/EntryListFallback.tsx | 85 +++- .../entries/UnifiedEntriesContent.tsx | 10 +- src/components/layout/Sidebar.tsx | 33 +- src/components/layout/TagList.tsx | 11 +- src/components/saved/FileUploadButton.tsx | 8 +- src/components/settings/OpmlImportExport.tsx | 10 +- .../pages/BrokenFeedsSettingsContent.tsx | 4 +- src/lib/cache/entry-cache.ts | 362 ------------------ src/lib/cache/operations.ts | 19 + src/lib/collections/tags.ts | 51 ++- src/lib/collections/writes.ts | 121 +++--- src/lib/hooks/useEntryMutations.ts | 20 +- src/lib/trpc/provider.tsx | 6 +- tests/unit/frontend/cache/operations.test.ts | 10 + 15 files changed, 261 insertions(+), 512 deletions(-) delete mode 100644 src/lib/cache/entry-cache.ts diff --git a/src/FRONTEND_STATE.md b/src/FRONTEND_STATE.md index 28e2e0f6..c96bda42 100644 --- a/src/FRONTEND_STATE.md +++ b/src/FRONTEND_STATE.md @@ -24,24 +24,11 @@ The frontend uses React Query (via tRPC) for server state management with a hybr Centralized helpers in `src/lib/cache/` ensure consistent updates across the codebase: -### Entry Cache (`entry-cache.ts`) - -| Function | Purpose | -| -------------------------- | ------------------------------------------------------------- | -| `updateEntriesReadStatus` | Updates `entries.get` cache + `entries.list` in-place | -| `updateEntryStarredStatus` | Updates `entries.get` cache + `entries.list` in-place | -| `updateEntryScoreInCache` | Updates score/implicitScore in `entries.get` + `entries.list` | - ### Count Cache (`count-cache.ts`) -| Function | Purpose | -| ------------------------------------- | ------------------------------------------------------ | -| `adjustSubscriptionUnreadCounts` | Directly updates unread counts in `subscriptions.list` | -| `adjustTagUnreadCounts` | Directly updates unread counts in `tags.list` | -| `adjustEntriesCount` | Directly updates `entries.count` cache | -| `addSubscriptionToCache` | Adds new subscription to `subscriptions.list` | -| `removeSubscriptionFromCache` | Removes subscription from `subscriptions.list` | -| `calculateTagDeltasFromSubscriptions` | Calculates tag deltas from subscription deltas | +| Function | Purpose | +| ------------------------------------- | ------------------------------------------------------------ | +| `calculateTagDeltasFromSubscriptions` | Calculates tag deltas from subscription deltas (collections) | ## Queries @@ -309,10 +296,8 @@ Returns the updated entry with score fields: | File | Purpose | | -------------------------------------------------- | ------------------------------------------------- | -| `src/lib/cache/index.ts` | Cache helper exports | | `src/lib/cache/operations.ts` | High-level cache operations (primary API) | -| `src/lib/cache/entry-cache.ts` | Low-level entry cache update helpers | -| `src/lib/cache/count-cache.ts` | Low-level subscription/tag count update helpers | +| `src/lib/cache/count-cache.ts` | Tag delta calculation from subscription deltas | | `src/lib/hooks/useEntryMutations.ts` | Entry mutations with cache updates | | `src/lib/hooks/useRealtimeUpdates.ts` | SSE connection and cache updates | | `src/lib/hooks/useKeyboardShortcuts.ts` | Keyboard navigation and entry selection | diff --git a/src/components/entries/EntryListFallback.tsx b/src/components/entries/EntryListFallback.tsx index 0fe2c941..864f17a3 100644 --- a/src/components/entries/EntryListFallback.tsx +++ b/src/components/entries/EntryListFallback.tsx @@ -2,14 +2,19 @@ * EntryListFallback Component * * Smart Suspense fallback for entry lists. Tries to show cached entries - * from parent lists (e.g., "All" list when viewing a subscription) while - * the actual query loads. Falls back to skeleton if no cached data available. + * from the TanStack DB entries collection (populated by SuspendingEntryList) + * while the actual query loads. Falls back to skeleton if no data available. + * + * Uses collection.state directly (non-reactive snapshot) rather than useLiveQuery + * to avoid SSR issues — useLiveQuery uses useSyncExternalStore without + * getServerSnapshot, which throws during hydration. Since this is a Suspense + * fallback that renders briefly, a non-reactive read is sufficient. */ "use client"; -import { useQueryClient } from "@tanstack/react-query"; -import { findParentListPlaceholderData } from "@/lib/cache/entry-cache"; +import { useMemo } from "react"; +import { useCollections } from "@/lib/collections/context"; import { EntryListItem } from "./EntryListItem"; import { EntryListSkeleton } from "./EntryListSkeleton"; import { EntryListLoadingMore } from "./EntryListStates"; @@ -41,9 +46,9 @@ interface EntryListFallbackProps { /** * Suspense fallback that shows cached entries when available. * - * Hierarchy for finding placeholder data: - * 1. For subscription pages: try the subscription's tag list first - * 2. Fall back to "All" list (no filters) + * Uses the entries collection (populated by SuspendingEntryList) to find + * entries matching the requested filters. For tag/uncategorized filtering, + * uses the subscriptions collection to look up subscription-tag relationships. * * If no cached data matches, renders a skeleton. */ @@ -53,23 +58,73 @@ export function EntryListFallback({ selectedEntryId, onEntryClick, }: EntryListFallbackProps) { - const queryClient = useQueryClient(); + const { entries: entriesCollection, subscriptions: subscriptionsCollection } = useCollections(); + + // Read non-reactive snapshots from collections — avoids useLiveQuery's + // useSyncExternalStore which crashes during SSR/hydration + const allEntries = Array.from(entriesCollection.state.values()); + const allSubscriptions = Array.from(subscriptionsCollection.state.values()); + + // Filter entries to match the requested view + const filteredEntries = useMemo(() => { + let result = allEntries; + + // Filter by subscription + if (filters.subscriptionId) { + result = result.filter((e) => e.subscriptionId === filters.subscriptionId); + } + + // Filter by tag — find subscriptions in the tag, then filter entries + if (filters.tagId) { + const subscriptionIdsInTag = new Set( + allSubscriptions + .filter((sub) => sub.tags.some((tag) => tag.id === filters.tagId)) + .map((sub) => sub.id) + ); + result = result.filter((e) => e.subscriptionId && subscriptionIdsInTag.has(e.subscriptionId)); + } + + // Filter by uncategorized — find subscriptions with no tags + if (filters.uncategorized) { + const uncategorizedSubscriptionIds = new Set( + allSubscriptions.filter((sub) => sub.tags.length === 0).map((sub) => sub.id) + ); + result = result.filter( + (e) => e.subscriptionId && uncategorizedSubscriptionIds.has(e.subscriptionId) + ); + } - // Try to find placeholder data from cached parent lists - // Subscriptions are automatically looked up from cache for tag/uncategorized filtering - const placeholderData = findParentListPlaceholderData(queryClient, filters); + // Filter by starred + if (filters.starredOnly) { + result = result.filter((e) => e.starred); + } + + // Filter by unread + if (filters.unreadOnly) { + result = result.filter((e) => !e.read); + } + + // Filter by type + if (filters.type) { + result = result.filter((e) => e.type === filters.type); + } + + // Sort by ID (UUIDv7 is time-ordered) + const ascending = filters.sortOrder === "oldest"; + result.sort((a, b) => (ascending ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id))); + + return result; + }, [allEntries, allSubscriptions, filters]); // No cached data - show skeleton - if (!placeholderData || placeholderData.pages[0]?.items.length === 0) { + if (filteredEntries.length === 0) { return ; } // Show cached entries with a subtle loading indicator - const entries = placeholderData.pages.flatMap((page) => page.items); - return (
- {entries.map((entry) => ( + {filteredEntries.map((entry) => ( import("./SuspendingEntryList").then((m) => m.SuspendingEntryList), + { ssr: false } +); import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { NotFoundCard } from "@/components/ui/not-found-card"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 9e36b590..af30c6c9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,22 +12,41 @@ "use client"; import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; -import { handleSubscriptionDeleted } from "@/lib/cache/operations"; +import { handleSubscriptionDeleted, refreshGlobalCounts } from "@/lib/cache/operations"; +import dynamic from "next/dynamic"; import { UnsubscribeDialog } from "@/components/feeds/UnsubscribeDialog"; import { EditSubscriptionDialog } from "@/components/feeds/EditSubscriptionDialog"; -import { SidebarNav } from "./SidebarNav"; import { TagList } from "./TagList"; +// SidebarNav uses useLiveQuery (TanStack DB) which calls useSyncExternalStore +// without getServerSnapshot, causing SSR to crash. Disable SSR for this component +// since the counts collection is client-only state anyway. +const SidebarNav = dynamic(() => import("./SidebarNav").then((m) => m.SidebarNav), { + ssr: false, + loading: () => , +}); + +/** + * Skeleton placeholder matching SidebarNav's layout (3 nav links). + */ +function SidebarNavSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); +} + interface SidebarProps { onClose?: () => void; } export function Sidebar({ onClose }: SidebarProps) { - const queryClient = useQueryClient(); const collections = useCollections(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; @@ -55,8 +74,8 @@ export function Sidebar({ onClose }: SidebarProps) { // Subscription infinite queries will re-populate the subscriptions collection. utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); collections.tags.utils.refetch(); + refreshGlobalCounts(utils, collections); }, }); @@ -64,9 +83,7 @@ export function Sidebar({ onClose }: SidebarProps) { // Mark entry list queries as stale and refetch active ones. // This ensures clicking the current page's link refreshes the list, // while cross-page navigation also works (new query fetches on mount). - queryClient.invalidateQueries({ - queryKey: [["entries", "list"]], - }); + utils.entries.list.invalidate(); onClose?.(); }; diff --git a/src/components/layout/TagList.tsx b/src/components/layout/TagList.tsx index cdf2e490..d6b38c37 100644 --- a/src/components/layout/TagList.tsx +++ b/src/components/layout/TagList.tsx @@ -10,6 +10,7 @@ import { Suspense, useMemo } from "react"; import { usePathname } from "next/navigation"; +import dynamic from "next/dynamic"; import { useLiveSuspenseQuery, useLiveQuery } from "@tanstack/react-db"; import { ClientLink } from "@/components/ui/client-link"; import { useCollections } from "@/lib/collections/context"; @@ -197,6 +198,14 @@ function TagListError() { return

Failed to load feeds

; } +// TagListContent uses useLiveQuery/useLiveSuspenseQuery (TanStack DB) which calls +// useSyncExternalStore without getServerSnapshot, causing SSR to crash. +// Disable SSR since the tags/counts collections are client-only state. +const DynamicTagListContent = dynamic(() => Promise.resolve({ default: TagListContent }), { + ssr: false, + loading: () => , +}); + /** * TagList with built-in Suspense and ErrorBoundary. */ @@ -204,7 +213,7 @@ export function TagList(props: TagListProps) { return ( }> }> - + ); diff --git a/src/components/saved/FileUploadButton.tsx b/src/components/saved/FileUploadButton.tsx index 2c6f3af2..d0547e03 100644 --- a/src/components/saved/FileUploadButton.tsx +++ b/src/components/saved/FileUploadButton.tsx @@ -10,6 +10,8 @@ import { useState, useRef, useCallback, type ChangeEvent, type DragEvent } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { adjustEntriesCountInCollection } from "@/lib/collections/writes"; import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { UploadIcon, DocumentIcon } from "@/components/ui/icon-button"; @@ -67,12 +69,14 @@ export function FileUploadButton({ className = "", onSuccess }: FileUploadButton const fileInputRef = useRef(null); const utils = trpc.useUtils(); + const collections = useCollections(); const uploadMutation = trpc.saved.uploadFile.useMutation({ onSuccess: () => { toast.success("File uploaded successfully"); - // Invalidate queries to refresh the saved list and count + // Invalidate entries list and update counts in collection utils.entries.list.invalidate({ type: "saved" }); - utils.entries.count.invalidate({ type: "saved" }); + adjustEntriesCountInCollection(collections, "saved", 1, 1); + adjustEntriesCountInCollection(collections, "all", 1, 1); onSuccess?.(); handleClose(); }, diff --git a/src/components/settings/OpmlImportExport.tsx b/src/components/settings/OpmlImportExport.tsx index 7463a9f3..3deee857 100644 --- a/src/components/settings/OpmlImportExport.tsx +++ b/src/components/settings/OpmlImportExport.tsx @@ -14,6 +14,8 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { refreshGlobalCounts } from "@/lib/cache/operations"; import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { UploadIcon, DownloadIcon, SpinnerIcon } from "@/components/ui/icon-button"; @@ -87,6 +89,7 @@ function ImportSection() { const fileInputRef = useRef(null); const utils = trpc.useUtils(); + const collections = useCollections(); // Query to poll for import status when importing const importQuery = trpc.imports.get.useQuery( @@ -122,7 +125,7 @@ function ImportSection() { return baseState; })(); - // Invalidate subscriptions when import completes + // Invalidate subscriptions and refresh counts when import completes const prevCompleted = useRef(false); useEffect(() => { const isCompleted = importState.type === "complete" && baseState.type === "importing"; @@ -130,7 +133,7 @@ function ImportSection() { prevCompleted.current = true; utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + refreshGlobalCounts(utils, collections); } else if (baseState.type !== "importing") { prevCompleted.current = false; } @@ -139,7 +142,8 @@ function ImportSection() { baseState.type, utils.subscriptions.list, utils.tags.list, - utils.entries.count, + utils, + collections, ]); const importMutation = trpc.subscriptions.import.useMutation({ diff --git a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx index 814e7de5..565acde7 100644 --- a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx +++ b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx @@ -11,7 +11,7 @@ import { useState } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; -import { handleSubscriptionDeleted } from "@/lib/cache/operations"; +import { handleSubscriptionDeleted, refreshGlobalCounts } from "@/lib/cache/operations"; import { getFeedDisplayName, formatRelativeTime, formatFutureTime } from "@/lib/format"; import { Button } from "@/components/ui/button"; import { SettingsListContainer } from "@/components/settings/SettingsListContainer"; @@ -62,7 +62,7 @@ export default function BrokenFeedsSettingsContent() { // On error, invalidate to refetch correct state utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + refreshGlobalCounts(utils, collections); }, }); diff --git a/src/lib/cache/entry-cache.ts b/src/lib/cache/entry-cache.ts deleted file mode 100644 index 546057dc..00000000 --- a/src/lib/cache/entry-cache.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * Entry Cache Helpers - * - * Provides placeholder data for Suspense fallbacks by looking up - * cached entry list data from React Query. - * - * Note: Entry state updates (read, starred, score) are handled by - * TanStack DB collections. This file only contains the placeholder - * data lookup for EntryListFallback. - */ - -import type { QueryClient } from "@tanstack/react-query"; - -/** - * Entry data in list cache. - */ -interface CachedListEntry { - id: string; - read: boolean; - starred: boolean; - subscriptionId?: string | null; - type?: string; - [key: string]: unknown; -} - -/** - * Page structure in infinite query cache. - */ -interface CachedPage { - items: CachedListEntry[]; - nextCursor?: string; -} - -/** - * Infinite query data structure. - */ -interface InfiniteData { - pages: CachedPage[]; - pageParams: unknown[]; -} - -/** - * Filter options for entry list queries. - */ -export interface EntryListFilters { - subscriptionId?: string; - tagId?: string; - uncategorized?: boolean; - unreadOnly?: boolean; - starredOnly?: boolean; - sortOrder?: "newest" | "oldest"; - type?: "web" | "email" | "saved"; -} - -/** - * Subscription info needed for tag filtering. - */ -interface SubscriptionInfo { - id: string; - tags: Array<{ id: string }>; -} - -/** - * Data format for regular queries. - */ -interface SubscriptionListData { - items: SubscriptionInfo[]; - nextCursor?: string; -} - -/** - * Data format for infinite queries. - */ -interface SubscriptionInfiniteData { - pages: SubscriptionListData[]; - pageParams: unknown[]; -} - -/** - * Looks up subscriptions from the cache for tag/uncategorized filtering. - * Handles both regular queries and infinite queries (used by sidebar). - * Returns undefined if not cached. - */ -function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] | undefined { - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - const allSubscriptions: SubscriptionInfo[] = []; - const seenIds = new Set(); - - for (const [, data] of queries) { - if (!data) continue; - - if ("pages" in data && Array.isArray(data.pages)) { - for (const page of data.pages) { - if (page?.items) { - for (const sub of page.items) { - if (!seenIds.has(sub.id)) { - seenIds.add(sub.id); - allSubscriptions.push(sub); - } - } - } - } - } else if ("items" in data && Array.isArray(data.items)) { - for (const sub of data.items) { - if (!seenIds.has(sub.id)) { - seenIds.add(sub.id); - allSubscriptions.push(sub); - } - } - } - } - - return allSubscriptions.length > 0 ? allSubscriptions : undefined; -} - -/** - * Query key structure for tRPC infinite queries. - */ -interface TRPCQueryKey { - input?: EntryListFilters & { limit?: number; cursor?: string }; - type?: string; -} - -/** - * Checks if a parent query's filters are compatible for use as placeholder data. - */ -function areFiltersCompatible( - parentFilters: EntryListFilters, - requestedFilters: EntryListFilters -): boolean { - const parentSort = parentFilters.sortOrder ?? "newest"; - const requestedSort = requestedFilters.sortOrder ?? "newest"; - if (parentSort !== requestedSort) return false; - if (parentFilters.starredOnly && !requestedFilters.starredOnly) return false; - if (parentFilters.unreadOnly && !requestedFilters.unreadOnly) return false; - if (parentFilters.type && parentFilters.type !== requestedFilters.type) return false; - if ( - parentFilters.subscriptionId && - parentFilters.subscriptionId !== requestedFilters.subscriptionId - ) - return false; - if (parentFilters.tagId && parentFilters.tagId !== requestedFilters.tagId) return false; - if (parentFilters.uncategorized && !requestedFilters.uncategorized) return false; - return true; -} - -/** - * Filters entries from a parent list to match the requested filters. - */ -function filterEntries( - entries: CachedListEntry[], - filters: EntryListFilters, - subscriptions?: SubscriptionInfo[] -): CachedListEntry[] { - let result = entries; - - if (filters.subscriptionId) { - result = result.filter((e) => e.subscriptionId === filters.subscriptionId); - } - - if (filters.tagId && subscriptions) { - const subscriptionIdsInTag = new Set( - subscriptions - .filter((sub) => sub.tags.some((tag) => tag.id === filters.tagId)) - .map((sub) => sub.id) - ); - result = result.filter( - (e) => e.subscriptionId && subscriptionIdsInTag.has(e.subscriptionId as string) - ); - } - - if (filters.uncategorized && subscriptions) { - const uncategorizedSubscriptionIds = new Set( - subscriptions.filter((sub) => sub.tags.length === 0).map((sub) => sub.id) - ); - result = result.filter( - (e) => e.subscriptionId && uncategorizedSubscriptionIds.has(e.subscriptionId as string) - ); - } - - if (filters.starredOnly) { - result = result.filter((e) => e.starred); - } - - if (filters.unreadOnly) { - result = result.filter((e) => !e.read); - } - - if (filters.type) { - result = result.filter((e) => e.type === filters.type); - } - - return result; -} - -/** - * Entry list item structure for placeholder data. - */ -interface EntryListItemForPlaceholder { - id: string; - subscriptionId: string | null; - feedId: string; - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - author: string | null; - summary: string | null; - publishedAt: Date | null; - fetchedAt: Date; - updatedAt: Date; - read: boolean; - starred: boolean; - feedTitle: string | null; - siteName: string | null; - score: number | null; - implicitScore: number; -} - -interface TypedPage { - items: EntryListItemForPlaceholder[]; - nextCursor?: string; -} - -interface TypedInfiniteData { - pages: TypedPage[]; - pageParams: (string | undefined)[]; -} - -function findCachedQuery( - queries: [readonly unknown[], InfiniteData | undefined][], - matchFilters: (parentFilters: EntryListFilters) => boolean -): InfiniteData | undefined { - for (const [queryKey, data] of queries) { - if (!data?.pages?.length) continue; - const keyMeta = queryKey[1] as TRPCQueryKey | undefined; - const parentFilters: EntryListFilters = keyMeta?.input ?? {}; - if (matchFilters(parentFilters)) { - return data; - } - } - return undefined; -} - -function findCachedQueryWithPreference( - queries: [readonly unknown[], InfiniteData | undefined][], - baseMatch: (pf: EntryListFilters) => boolean, - filters: EntryListFilters -): InfiniteData | undefined { - let result = findCachedQuery( - queries, - (pf) => - baseMatch(pf) && - !!pf.unreadOnly === !!filters.unreadOnly && - !!pf.starredOnly === !!filters.starredOnly && - areFiltersCompatible(pf, filters) - ); - - if (!result) { - result = findCachedQuery(queries, (pf) => baseMatch(pf) && areFiltersCompatible(pf, filters)); - } - - return result; -} - -function filtersEqual(a: EntryListFilters, b: EntryListFilters): boolean { - return ( - a.subscriptionId === b.subscriptionId && - a.tagId === b.tagId && - !!a.uncategorized === !!b.uncategorized && - a.type === b.type && - !!a.unreadOnly === !!b.unreadOnly && - !!a.starredOnly === !!b.starredOnly && - (a.sortOrder ?? "newest") === (b.sortOrder ?? "newest") - ); -} - -/** - * Finds placeholder data from a parent list cache that can be used while the actual query loads. - * Walks up the hierarchy looking for cached data: - * - * 1. Self-cache: exact same query already cached (e.g., "All" returning to "All") - * 2. For subscriptions: tag list (if subscription is in a tag) → "All" list - * 3. For tags/starred/other: "All" list - * - * Within each level, prefers exact unreadOnly/starredOnly match, then falls back to compatible. - * - * For tag/uncategorized filtering, automatically looks up subscriptions from the cache. - * - * @param queryClient - React Query client for cache access - * @param filters - Requested filters for the entry list - * @returns Placeholder data in infinite query format, or undefined if no suitable parent found - */ -export function findParentListPlaceholderData( - queryClient: QueryClient, - filters: EntryListFilters -): TypedInfiniteData | undefined { - const needsSubscriptions = filters.tagId || filters.uncategorized; - const subscriptions = needsSubscriptions ? getSubscriptionsFromCache(queryClient) : undefined; - - if (needsSubscriptions && !subscriptions) { - return undefined; - } - - const queries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - // 1. Check for exact match first (self-cache) - const exactMatch = findCachedQuery(queries, (pf) => filtersEqual(pf, filters)); - if (exactMatch) { - return { - pages: exactMatch.pages.map((p) => ({ - items: p.items as unknown as EntryListItemForPlaceholder[], - nextCursor: p.nextCursor, - })), - pageParams: exactMatch.pageParams as (string | undefined)[], - }; - } - - let parentData: InfiniteData | undefined; - - // 2. For subscription pages, try the subscription's tag list - if (filters.subscriptionId && subscriptions) { - const subscription = subscriptions.find((s) => s.id === filters.subscriptionId); - const tagIds = subscription?.tags.map((t) => t.id) ?? []; - - for (const tagId of tagIds) { - parentData = findCachedQueryWithPreference( - queries, - (pf) => pf.tagId === tagId && !pf.subscriptionId, - filters - ); - if (parentData) break; - } - } - - // 3. Fall back to "All" list - if (!parentData) { - parentData = findCachedQueryWithPreference( - queries, - (pf) => !pf.subscriptionId && !pf.tagId && !pf.uncategorized, - filters - ); - } - - if (!parentData) return undefined; - - const allEntries = parentData.pages.flatMap((page) => page.items); - const filteredEntries = filterEntries(allEntries, filters, subscriptions); - - if (filteredEntries.length === 0) return undefined; - - return { - pages: [ - { items: filteredEntries as unknown as EntryListItemForPlaceholder[], nextCursor: undefined }, - ], - pageParams: [undefined], - }; -} diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index cd1ee889..15e528c5 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -250,3 +250,22 @@ export function setBulkCounts(collections: Collections | null, counts: BulkUnrea setUncategorizedUnreadInCollection(collections, counts.uncategorized.unread); } } + +/** + * Fetches fresh global counts from the server and writes to the counts collection. + * Used after operations that don't return counts (markAllRead, OPML import, error recovery). + */ +export async function refreshGlobalCounts( + utils: TRPCClientUtils, + collections: Collections | null +): Promise { + if (!collections) return; + const [all, starred, saved] = await Promise.all([ + utils.entries.count.fetch({}), + utils.entries.count.fetch({ starredOnly: true }), + utils.entries.count.fetch({ type: "saved" }), + ]); + setEntriesCountInCollection(collections, "all", all.total, all.unread); + setEntriesCountInCollection(collections, "starred", starred.total, starred.unread); + setEntriesCountInCollection(collections, "saved", saved.total, saved.unread); +} diff --git a/src/lib/collections/tags.ts b/src/lib/collections/tags.ts index 289a85c0..7aac7cdb 100644 --- a/src/lib/collections/tags.ts +++ b/src/lib/collections/tags.ts @@ -5,6 +5,9 @@ * Small dataset (<100 items), needed everywhere (sidebar, filtering). * Loads all tags upfront via a single query. * + * Uses the tRPC tags.list query key directly so it shares the same cache + * entry as the SSR prefetch, avoiding a duplicate network fetch on load. + * * The tags.list API also returns uncategorized counts, which are stored * in the counts collection under the "uncategorized" key. */ @@ -15,11 +18,26 @@ import type { QueryClient } from "@tanstack/react-query"; import type { CountsCollection } from "./counts"; import type { TagItem, UncategorizedCounts } from "./types"; +/** Shape of the tRPC tags.list response */ +interface TagsListResponse { + items: TagItem[]; + uncategorized: UncategorizedCounts; +} + +/** + * tRPC query key for tags.list (no input). + * + * Format: [path, { type }] where path is the split procedure path. + * See @trpc/react-query getQueryKeyInternal for the key generation logic. + */ +const TRPC_TAGS_LIST_KEY = [["tags", "list"], { type: "query" }] as const; + /** * Creates the tags collection backed by TanStack Query. * * Uses `select` to extract the `items` array from the tags.list response. - * Also writes uncategorized counts to the counts collection as a side effect. + * Also writes uncategorized counts to the counts collection as a side effect + * of the select function (runs on every successful fetch/refetch). * * @param queryClient - The shared QueryClient instance * @param fetchTagsAndUncategorized - Function to fetch tags + uncategorized counts from the API @@ -27,36 +45,35 @@ import type { TagItem, UncategorizedCounts } from "./types"; */ export function createTagsCollection( queryClient: QueryClient, - fetchTagsAndUncategorized: () => Promise<{ - items: TagItem[]; - uncategorized: UncategorizedCounts; - }>, + fetchTagsAndUncategorized: () => Promise, countsCollection: CountsCollection ) { return createCollection( queryCollectionOptions({ id: "tags", - queryKey: ["tags", "listAll"] as const, + // Use the tRPC query key so the collection shares the SSR-prefetched cache entry + queryKey: TRPC_TAGS_LIST_KEY as unknown as readonly unknown[], queryFn: async () => { - const result = await fetchTagsAndUncategorized(); - - // Store uncategorized counts in the counts collection + return await fetchTagsAndUncategorized(); + }, + // Extract items array from the { items, uncategorized } response + select: (data: TagsListResponse) => { + // Side effect: write uncategorized counts to the counts collection const existing = countsCollection.get("uncategorized"); if (existing) { - countsCollection.utils.writeUpdate({ - id: "uncategorized", - total: result.uncategorized.feedCount, - unread: result.uncategorized.unreadCount, + countsCollection.update("uncategorized", (draft) => { + draft.total = data.uncategorized.feedCount; + draft.unread = data.uncategorized.unreadCount; }); } else { - countsCollection.utils.writeInsert({ + countsCollection.insert({ id: "uncategorized", - total: result.uncategorized.feedCount, - unread: result.uncategorized.unreadCount, + total: data.uncategorized.feedCount, + unread: data.uncategorized.unreadCount, }); } - return result.items; + return data.items; }, queryClient, getKey: (item: TagItem) => item.id, diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index 85425e41..38ec8b09 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -4,16 +4,18 @@ * Functions that update TanStack DB collections for client-side state management. * Each function accepts `Collections | null` and no-ops when null. * - * Uses `collection.utils.writeUpdate()` for query-backed collections, which writes - * directly to the synced data store without triggering onInsert/onUpdate handlers - * or query refetches. + * Local-only collections (subscriptions, entries, counts) use direct mutation + * methods: collection.insert(), collection.update(), collection.delete(). + * + * Query-backed collections (tags) use collection.utils.writeInsert/writeUpdate/writeDelete + * which write directly to the synced data store. */ import type { Collections } from "./index"; import type { Subscription, TagItem, EntryListItem } from "./types"; // ============================================================================ -// Subscription Collection Writes +// Subscription Collection Writes (local-only) // ============================================================================ /** @@ -26,16 +28,14 @@ export function adjustSubscriptionUnreadInCollection( ): void { if (!collections || subscriptionDeltas.size === 0) return; - const updates: Array & { id: string }> = []; for (const [id, delta] of subscriptionDeltas) { const current = collections.subscriptions.get(id); if (current) { - updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + collections.subscriptions.update(id, (draft) => { + draft.unreadCount = Math.max(0, current.unreadCount + delta); + }); } } - if (updates.length > 0) { - collections.subscriptions.utils.writeUpdate(updates); - } } /** @@ -51,7 +51,9 @@ export function setSubscriptionUnreadInCollection( const current = collections.subscriptions.get(subscriptionId); if (current) { - collections.subscriptions.utils.writeUpdate({ id: subscriptionId, unreadCount: unread }); + collections.subscriptions.update(subscriptionId, (draft) => { + draft.unreadCount = unread; + }); } } @@ -65,16 +67,14 @@ export function setBulkSubscriptionUnreadInCollection( ): void { if (!collections || updates.size === 0) return; - const writeUpdates: Array & { id: string }> = []; for (const [id, unread] of updates) { const current = collections.subscriptions.get(id); if (current) { - writeUpdates.push({ id, unreadCount: unread }); + collections.subscriptions.update(id, (draft) => { + draft.unreadCount = unread; + }); } } - if (writeUpdates.length > 0) { - collections.subscriptions.utils.writeUpdate(writeUpdates); - } } /** @@ -90,7 +90,7 @@ export function addSubscriptionToCollection( // Check for duplicates (SSE race condition) if (collections.subscriptions.has(subscription.id)) return; - collections.subscriptions.utils.writeInsert(subscription); + collections.subscriptions.insert(subscription); } /** @@ -104,7 +104,7 @@ export function removeSubscriptionFromCollection( if (!collections) return; if (collections.subscriptions.has(subscriptionId)) { - collections.subscriptions.utils.writeDelete(subscriptionId); + collections.subscriptions.delete(subscriptionId); } } @@ -119,27 +119,19 @@ export function upsertSubscriptionsInCollection( ): void { if (!collections || subscriptions.length === 0) return; - const inserts: Subscription[] = []; - const updates: Array & { id: string }> = []; - for (const sub of subscriptions) { if (collections.subscriptions.has(sub.id)) { - updates.push(sub); + collections.subscriptions.update(sub.id, (draft) => { + Object.assign(draft, sub); + }); } else { - inserts.push(sub); + collections.subscriptions.insert(sub); } } - - if (inserts.length > 0) { - collections.subscriptions.utils.writeInsert(inserts); - } - if (updates.length > 0) { - collections.subscriptions.utils.writeUpdate(updates); - } } // ============================================================================ -// Tag Collection Writes +// Tag Collection Writes (query-backed) // ============================================================================ /** @@ -257,7 +249,7 @@ export function removeTagFromCollection(collections: Collections | null, tagId: } // ============================================================================ -// Entry Count Writes (via Counts Collection) +// Entry Count Writes (via Counts Collection, local-only) // ============================================================================ /** @@ -273,9 +265,12 @@ export function setEntriesCountInCollection( if (!collections) return; if (collections.counts.has(key)) { - collections.counts.utils.writeUpdate({ id: key, total, unread }); + collections.counts.update(key, (draft) => { + draft.total = total; + draft.unread = unread; + }); } else { - collections.counts.utils.writeInsert({ id: key, total, unread }); + collections.counts.insert({ id: key, total, unread }); } } @@ -293,10 +288,9 @@ export function adjustEntriesCountInCollection( const current = collections.counts.get(key); if (current) { - collections.counts.utils.writeUpdate({ - id: key, - total: Math.max(0, current.total + totalDelta), - unread: Math.max(0, current.unread + unreadDelta), + collections.counts.update(key, (draft) => { + draft.total = Math.max(0, current.total + totalDelta); + draft.unread = Math.max(0, current.unread + unreadDelta); }); } } @@ -313,7 +307,9 @@ export function setUncategorizedUnreadInCollection( const current = collections.counts.get("uncategorized"); if (current) { - collections.counts.utils.writeUpdate({ id: "uncategorized", unread }); + collections.counts.update("uncategorized", (draft) => { + draft.unread = unread; + }); } } @@ -333,9 +329,8 @@ export function adjustUncategorizedUnreadInCollection( const current = collections.counts.get("uncategorized"); if (current) { - collections.counts.utils.writeUpdate({ - id: "uncategorized", - unread: Math.max(0, current.unread + delta), + collections.counts.update("uncategorized", (draft) => { + draft.unread = Math.max(0, current.unread + delta); }); } } @@ -352,15 +347,14 @@ export function adjustUncategorizedFeedCountInCollection( const current = collections.counts.get("uncategorized"); if (current) { - collections.counts.utils.writeUpdate({ - id: "uncategorized", - total: Math.max(0, current.total + delta), + collections.counts.update("uncategorized", (draft) => { + draft.total = Math.max(0, current.total + delta); }); } } // ============================================================================ -// Entry Collection Writes +// Entry Collection Writes (local-only) // ============================================================================ /** @@ -376,15 +370,13 @@ export function updateEntryReadInCollection( ): void { if (!collections || entryIds.length === 0) return; - const updates: Array & { id: string }> = []; for (const id of entryIds) { if (collections.entries.has(id)) { - updates.push({ id, read }); + collections.entries.update(id, (draft) => { + draft.read = read; + }); } } - if (updates.length > 0) { - collections.entries.utils.writeUpdate(updates); - } } /** @@ -398,7 +390,9 @@ export function updateEntryStarredInCollection( if (!collections) return; if (collections.entries.has(entryId)) { - collections.entries.utils.writeUpdate({ id: entryId, starred }); + collections.entries.update(entryId, (draft) => { + draft.starred = starred; + }); } } @@ -414,7 +408,10 @@ export function updateEntryScoreInCollection( if (!collections) return; if (collections.entries.has(entryId)) { - collections.entries.utils.writeUpdate({ id: entryId, score, implicitScore }); + collections.entries.update(entryId, (draft) => { + draft.score = score; + draft.implicitScore = implicitScore; + }); } } @@ -430,7 +427,9 @@ export function updateEntryMetadataInCollection( if (!collections) return; if (collections.entries.has(entryId)) { - collections.entries.utils.writeUpdate({ id: entryId, ...metadata }); + collections.entries.update(entryId, (draft) => { + Object.assign(draft, metadata); + }); } } @@ -447,21 +446,13 @@ export function upsertEntriesInCollection( ): void { if (!collections || entries.length === 0) return; - const inserts: EntryListItem[] = []; - const updates: Array & { id: string }> = []; - for (const entry of entries) { if (collections.entries.has(entry.id)) { - updates.push(entry); + collections.entries.update(entry.id, (draft) => { + Object.assign(draft, entry); + }); } else { - inserts.push(entry); + collections.entries.insert(entry); } } - - if (inserts.length > 0) { - collections.entries.utils.writeInsert(inserts); - } - if (updates.length > 0) { - collections.entries.utils.writeUpdate(updates); - } } diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index b5f4e077..44696547 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -23,7 +23,7 @@ import { useCallback, useMemo, useRef } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; -import { setCounts, setBulkCounts } from "@/lib/cache/operations"; +import { setCounts, setBulkCounts, refreshGlobalCounts } from "@/lib/cache/operations"; import { updateEntryReadInCollection, updateEntryStarredInCollection, @@ -372,24 +372,16 @@ export function useEntryMutations(): UseEntryMutationsResult { }, }); - // markAllRead mutation - invalidates caches based on what could be affected + // markAllRead mutation - invalidates caches and refreshes counts const markAllReadMutation = trpc.entries.markAllRead.useMutation({ - onSuccess: (_data, variables) => { + onSuccess: () => { utils.entries.list.invalidate(); utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); + collections.tags.utils.refetch(); - // All Articles count is always affected - utils.entries.count.invalidate({}); - - // Starred count is always affected since starred entries can exist in any view - utils.entries.count.invalidate({ starredOnly: true }); - - // Invalidate saved count if saved entries could be affected - // (either type: "saved" was set, or no type filter means all including saved) - if (variables.type === "saved" || !variables.type) { - utils.entries.count.invalidate({ type: "saved" }); - } + // markAllRead doesn't return counts, so fetch fresh counts from server + refreshGlobalCounts(utils, collections); }, onError: () => { toast.error("Failed to mark all as read"); diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index dceef5d7..8c8fdadf 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -182,11 +182,11 @@ export function TRPCProvider({ children }: TRPCProviderProps) { const keyMeta = queryKey[1] as { input?: Record } | undefined; const input = keyMeta?.input; if (!input || Object.keys(input).length === 0) { - cols.counts.utils.writeInsert({ id: "all", total: data.total, unread: data.unread }); + cols.counts.insert({ id: "all", total: data.total, unread: data.unread }); } else if (input.starredOnly === true) { - cols.counts.utils.writeInsert({ id: "starred", total: data.total, unread: data.unread }); + cols.counts.insert({ id: "starred", total: data.total, unread: data.unread }); } else if (input.type === "saved") { - cols.counts.utils.writeInsert({ id: "saved", total: data.total, unread: data.unread }); + cols.counts.insert({ id: "saved", total: data.total, unread: data.unread }); } } diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index a80c3502..dfb55345 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -15,6 +15,7 @@ import { handleSubscriptionDeleted, setCounts, setBulkCounts, + refreshGlobalCounts, type SubscriptionData, type UnreadCounts, type BulkUnreadCounts, @@ -174,3 +175,12 @@ describe("setBulkCounts", () => { setBulkCounts(null, counts); }); }); + +describe("refreshGlobalCounts", () => { + it("does nothing with null collections", async () => { + const mockUtils = createMockTrpcUtils(); + await refreshGlobalCounts(mockUtils.utils, null); + // No fetch operations should have been issued + expect(mockUtils.operations).toHaveLength(0); + }); +}); From 08d3b579f73e816f38c44d1b237e1cc4c6ca6db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 03:52:12 +0000 Subject: [PATCH 08/31] Implement Phase 6: on-demand collections with collection-driven rendering Replace React Query-driven pagination with TanStack DB on-demand collections as the primary data source for entry and subscription lists. Collections now fetch pages from the server as the user scrolls, bridging TanStack DB's offset-based loading to cursor-based pagination via an offset-to-cursor map. Key changes: - Entry lists render from useLiveInfiniteQuery over per-view collections - Display stability: entries visible when view loaded don't disappear on state changes (e.g., marking read in "unread only" view) - Sidebar tag subscription lists use per-tag on-demand collections - Entry navigation (next/prev for swipe gestures) shared via external store - Mutations and SSE writes propagate to active view collection - SSR prefetch seeding preserved via React Query cache check Co-Authored-By: Claude Opus 4.6 --- .../entries/SuspendingEntryList.tsx | 186 +++++++++------ .../entries/UnifiedEntriesContent.tsx | 70 ++---- src/components/layout/TagSubscriptionList.tsx | 70 ++++-- src/lib/collections/entries.ts | 214 ++++++++++++++++-- src/lib/collections/index.ts | 14 +- src/lib/collections/subscriptions.ts | 121 ++++++++-- src/lib/collections/writes.ts | 47 +++- src/lib/hooks/useEntryNavigation.ts | 94 ++++++++ src/lib/hooks/useStableEntryList.ts | 151 ++++++++++++ .../hooks/useTagSubscriptionsCollection.ts | 46 ++++ src/lib/hooks/useViewEntriesCollection.ts | 68 ++++++ src/lib/trpc/provider.tsx | 16 +- src/lib/trpc/vanilla-client.ts | 31 +++ 13 files changed, 940 insertions(+), 188 deletions(-) create mode 100644 src/lib/hooks/useEntryNavigation.ts create mode 100644 src/lib/hooks/useStableEntryList.ts create mode 100644 src/lib/hooks/useTagSubscriptionsCollection.ts create mode 100644 src/lib/hooks/useViewEntriesCollection.ts create mode 100644 src/lib/trpc/vanilla-client.ts diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index 4b7c5c49..cc863888 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -1,18 +1,24 @@ /** * SuspendingEntryList Component * - * A wrapper around EntryList that uses useSuspenseInfiniteQuery. - * Suspends until entry data is ready, allowing independent loading - * from other parts of the page (like entry content). + * Renders the entry list using a TanStack DB on-demand collection. + * The collection fetches pages from the server as the user scrolls, + * bridging TanStack DB's offset-based windowing to our cursor-based API. * - * Uses useEntriesListInput to get query input, ensuring cache is shared - * with the parent's non-suspending query (used for navigation). + * Uses useLiveInfiniteQuery for reactive pagination and useStableEntryList + * to prevent entries from disappearing when their state changes mid-session + * (e.g., marking an entry as read while viewing "unread only"). + * + * Note: Despite the name, this component no longer uses React Suspense. + * The name is kept for compatibility with the dynamic import in + * UnifiedEntriesContent. Loading state is handled via isLoading/isReady. */ "use client"; import { useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import { useLiveQuery } from "@tanstack/react-db"; +import { useLiveInfiniteQuery } from "@tanstack/react-db"; +import { eq } from "@tanstack/db"; import { trpc } from "@/lib/trpc/client"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; @@ -23,7 +29,12 @@ import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { useScrollContainer } from "@/components/layout/ScrollContainerContext"; import { useCollections } from "@/lib/collections/context"; import { upsertEntriesInCollection } from "@/lib/collections/writes"; +import { useViewEntriesCollection } from "@/lib/hooks/useViewEntriesCollection"; +import { useStableEntryList } from "@/lib/hooks/useStableEntryList"; +import { useEntryNavigationUpdater } from "@/lib/hooks/useEntryNavigation"; +import type { EntriesViewFilters } from "@/lib/collections/entries"; import { EntryList, type ExternalQueryState } from "./EntryList"; +import { EntryListSkeleton } from "./EntryListSkeleton"; interface SuspendingEntryListProps { emptyMessage: string; @@ -37,60 +48,99 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) const scrollContainerRef = useScrollContainer(); const collections = useCollections(); - // Get query input from URL - shared with parent's non-suspending query + // Get query input from URL const queryInput = useEntriesListInput(); - // Suspending query - component suspends until data is ready - // Shares cache with parent's useInfiniteQuery via same queryInput - const [data, { fetchNextPage, hasNextPage, isFetchingNextPage, refetch }] = - trpc.entries.list.useSuspenseInfiniteQuery(queryInput, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Infinity, - refetchOnWindowFocus: false, - }); - - // Flatten all page items for collection population - const allPageItems = useMemo( - () => data?.pages.flatMap((page) => page.items) ?? [], - [data?.pages] + // Build filters for the on-demand view collection + const viewFilters: EntriesViewFilters = useMemo( + () => ({ + subscriptionId: queryInput.subscriptionId, + tagId: queryInput.tagId, + uncategorized: queryInput.uncategorized, + unreadOnly: showUnreadOnly, + starredOnly: queryInput.starredOnly, + sortOrder, + type: queryInput.type, + limit: queryInput.limit, + }), + [queryInput, showUnreadOnly, sortOrder] ); - // Populate entries collection from tRPC pages as they load. - // This makes entries available for O(1) lookup by mutations/SSE. - useEffect(() => { - upsertEntriesInCollection(collections, allPageItems); - }, [collections, allPageItems]); + // Create the on-demand view collection (recreates on filter change) + const { collection: viewCollection, filterKey } = useViewEntriesCollection(viewFilters); + + const sortDescending = sortOrder === "newest"; + + // Live infinite query over the view collection + // Client-side where clauses ensure correct hasNextPage / dataNeeded calculation + const { + data: liveData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isReady, + } = useLiveInfiniteQuery( + (q) => { + let query = q + .from({ e: viewCollection }) + .orderBy(({ e }) => e._sortMs, sortDescending ? "desc" : "asc"); - // Reactive subscription to entries collection state. - // Re-renders when mutations/SSE call writeUpdate on the collection, - // providing live mutable state (read, starred) to overlay on page items. - const { state: entriesState } = useLiveQuery(collections.entries); + // Client-side filter matching server-side filter for correct pagination + if (showUnreadOnly) { + query = query.where(({ e }) => eq(e.read, false)); + } + if (queryInput.starredOnly) { + query = query.where(({ e }) => eq(e.starred, true)); + } + + return query.select(({ e }) => ({ ...e })); + }, + { + pageSize: queryInput.limit, + getNextPageParam: (lastPage, allPages) => + lastPage.length === queryInput.limit ? allPages.length : undefined, + }, + [filterKey, sortDescending, showUnreadOnly, queryInput.starredOnly] + ); - // Build entries by merging page items (ordering/identity) with collection state - // (mutable fields like read/starred). The collection is the source of truth for - // mutable state after optimistic updates, while tRPC pages provide ordering. + // Display stability: merge live entries with previously-seen entries + const stableEntries = useStableEntryList( + liveData ?? [], + viewCollection, + filterKey, + sortDescending + ); + + // Populate global entries collection from live query results. + // This keeps entries available for SSE state updates, fallback lookups, + // and the detail view overlay. + useEffect(() => { + if (stableEntries.length > 0) { + upsertEntriesInCollection(collections, stableEntries); + } + }, [collections, stableEntries]); + + // Map to EntryListItem shape (strip _sortMs for downstream compatibility) const entries = useMemo( () => - allPageItems.map((entry) => { - const live = entriesState.get(entry.id); - return { - id: entry.id, - feedId: entry.feedId, - subscriptionId: entry.subscriptionId, - type: entry.type, - url: entry.url, - title: live?.title ?? entry.title, - author: live?.author ?? entry.author, - summary: live?.summary ?? entry.summary, - publishedAt: entry.publishedAt, - fetchedAt: entry.fetchedAt, - read: live?.read ?? entry.read, - starred: live?.starred ?? entry.starred, - feedTitle: entry.feedTitle, - siteName: entry.siteName, - }; - }), - [allPageItems, entriesState] + stableEntries.map((entry) => ({ + id: entry.id, + feedId: entry.feedId, + subscriptionId: entry.subscriptionId, + type: entry.type, + url: entry.url, + title: entry.title, + author: entry.author, + summary: entry.summary, + publishedAt: entry.publishedAt, + fetchedAt: entry.fetchedAt, + read: entry.read, + starred: entry.starred, + feedTitle: entry.feedTitle, + siteName: entry.siteName, + })), + [stableEntries] ); // Compute next/previous entry IDs for keyboard navigation @@ -110,6 +160,12 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) }; }, [openEntryId, entries]); + // Publish navigation state for swipe gestures in EntryContent + const updateNavigation = useEntryNavigationUpdater(); + useEffect(() => { + updateNavigation({ nextEntryId, previousEntryId }); + }, [updateNavigation, nextEntryId, previousEntryId]); + // Trigger pagination when navigating close to the end of loaded entries const prevDistanceToEnd = useRef(distanceToEnd); useEffect(() => { @@ -205,19 +261,21 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) onNavigatePrevious: goToPreviousEntry, }); + // Show skeleton while first page loads + if (isLoading && !isReady) { + return ; + } + // External query state for EntryList - const externalQueryState: ExternalQueryState = useMemo( - () => ({ - isLoading: false, // Suspense handles loading - isError: false, // ErrorBoundary handles errors - errorMessage: undefined, - isFetchingNextPage, - hasNextPage: hasNextPage ?? false, - fetchNextPage, - refetch, - }), - [isFetchingNextPage, hasNextPage, fetchNextPage, refetch] - ); + const externalQueryState: ExternalQueryState = { + isLoading: isLoading && !isReady, + isError: false, + errorMessage: undefined, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch: () => utils.entries.list.invalidate(), + }; return ( import("./SuspendingEntryList").then((m) => m.SuspendingEntryList), { ssr: false } @@ -36,6 +36,11 @@ import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { type ViewType } from "@/lib/hooks/viewPreferences"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; +import { + createEntryNavigationStore, + EntryNavigationProvider, + useEntryNavigationState, +} from "@/lib/hooks/useEntryNavigation"; import { type EntryType } from "@/lib/hooks/useEntryMutations"; /** @@ -291,15 +296,11 @@ function UnifiedEntriesContentInner() { const { showUnreadOnly } = useUrlViewPreferences(); const { openEntryId, setOpenEntryId, closeEntry } = useEntryUrlState(); - // Get query input based on current URL - shared with SuspendingEntryList + // Get query input based on current URL const queryInput = useEntriesListInput(); - // Non-suspending query for navigation - shares cache with SuspendingEntryList - const entriesQuery = trpc.entries.list.useInfiniteQuery(queryInput, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Infinity, - refetchOnWindowFocus: false, - }); + // Navigation state published by SuspendingEntryList via useEntryNavigationUpdater + const { nextEntryId, previousEntryId } = useEntryNavigationState(); // Fetch subscription data for validation const subscriptionQuery = trpc.subscriptions.get.useQuery( @@ -351,42 +352,6 @@ function UnifiedEntriesContentInner() { return options; }, [routeInfo.filters]); - // Get adjacent entry IDs from query data for navigation - // Also compute distance to end for pagination triggering - const pages = entriesQuery.data?.pages; - const { nextEntryId, previousEntryId, distanceToEnd } = useMemo(() => { - if (!openEntryId || !pages) { - return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; - } - const allEntries = pages.flatMap((page) => page.items); - const currentIndex = allEntries.findIndex((e) => e.id === openEntryId); - if (currentIndex === -1) { - return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; - } - return { - nextEntryId: - currentIndex < allEntries.length - 1 ? allEntries[currentIndex + 1].id : undefined, - previousEntryId: currentIndex > 0 ? allEntries[currentIndex - 1].id : undefined, - distanceToEnd: allEntries.length - 1 - currentIndex, - }; - }, [openEntryId, pages]); - - // Trigger pagination when navigating close to the end of loaded entries - // This ensures swipe navigation can continue beyond the initial page - const prevDistanceToEnd = useRef(distanceToEnd); - useEffect(() => { - const PAGINATION_THRESHOLD = 3; - if ( - distanceToEnd <= PAGINATION_THRESHOLD && - distanceToEnd < prevDistanceToEnd.current && - entriesQuery.hasNextPage && - !entriesQuery.isFetchingNextPage - ) { - entriesQuery.fetchNextPage(); - } - prevDistanceToEnd.current = distanceToEnd; - }, [distanceToEnd, entriesQuery]); - // Navigation callbacks - just update URL, React re-renders const handleSwipeNext = useMemo(() => { if (!nextEntryId) return undefined; @@ -484,14 +449,17 @@ function UnifiedEntriesContentInner() { * to determine what to render. When navigation happens via pushState, usePathname() * updates and this component re-renders with the appropriate content. * - * Note: No outer Suspense needed because UnifiedEntriesContentInner uses only - * non-suspending queries. All suspending queries are inside child components - * with their own Suspense boundaries (title, entry list, entry content). + * Provides EntryNavigationProvider so SuspendingEntryList can publish + * next/previous entry IDs for swipe gesture navigation in EntryContent. */ export function UnifiedEntriesContent() { + const [navigationStore] = useState(createEntryNavigationStore); + return ( - + + + ); } diff --git a/src/components/layout/TagSubscriptionList.tsx b/src/components/layout/TagSubscriptionList.tsx index 25eb726f..3f99ee89 100644 --- a/src/components/layout/TagSubscriptionList.tsx +++ b/src/components/layout/TagSubscriptionList.tsx @@ -1,20 +1,21 @@ /** * TagSubscriptionList Component * - * Renders subscriptions within a tag section using infinite scrolling. - * Subscriptions are fetched per-tag (or uncategorized) when the section is expanded, - * with more pages loaded automatically as the user scrolls. + * Renders subscriptions within a tag section using a TanStack DB on-demand collection. + * The collection fetches pages from the server as the user scrolls in the sidebar. * - * Loaded subscriptions are also written into the TanStack DB subscriptions collection + * Loaded subscriptions are also written into the global subscriptions collection * for fast lookups and optimistic updates elsewhere in the app. */ "use client"; import { useEffect, useMemo, useRef } from "react"; -import { trpc } from "@/lib/trpc/client"; +import { useLiveInfiniteQuery } from "@tanstack/react-db"; import { useCollections } from "@/lib/collections/context"; import { upsertSubscriptionsInCollection } from "@/lib/collections/writes"; +import { useTagSubscriptionsCollection } from "@/lib/hooks/useTagSubscriptionsCollection"; +import type { TagSubscriptionFilters } from "@/lib/collections/subscriptions"; import { SubscriptionItem } from "./SubscriptionItem"; interface TagSubscriptionListProps { @@ -37,6 +38,8 @@ interface TagSubscriptionListProps { onUnsubscribe: (sub: { id: string; title: string }) => void; } +const PAGE_SIZE = 50; + export function TagSubscriptionList({ tagId, uncategorized, @@ -48,26 +51,45 @@ export function TagSubscriptionList({ const sentinelRef = useRef(null); const collections = useCollections(); - const subscriptionsQuery = trpc.subscriptions.list.useInfiniteQuery( - { tagId, uncategorized, limit: 50 }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - } + // Build filters for the on-demand collection + const filters: TagSubscriptionFilters = useMemo( + () => ({ tagId, uncategorized, limit: PAGE_SIZE }), + [tagId, uncategorized] ); - const allSubscriptions = useMemo( - () => subscriptionsQuery.data?.pages.flatMap((p) => p.items) ?? [], - [subscriptionsQuery.data] - ); + // Create the on-demand collection (recreates on filter change) + const { collection: tagCollection, filterKey } = useTagSubscriptionsCollection(filters); - const { hasNextPage, isFetchingNextPage, fetchNextPage } = subscriptionsQuery; + // Live infinite query over the tag subscription collection + // Server sorts alphabetically by title, so we use ID as the orderBy + // (UUIDv7 gives us stable ordering; the server determines the actual sort) + const { + data: subscriptions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isReady, + } = useLiveInfiniteQuery( + (q) => + q + .from({ s: tagCollection }) + .orderBy(({ s }) => s.id, "asc") + .select(({ s }) => ({ ...s })), + { + pageSize: PAGE_SIZE, + getNextPageParam: (lastPage, allPages) => + lastPage.length === PAGE_SIZE ? allPages.length : undefined, + }, + [filterKey] + ); - // Write loaded subscriptions into the TanStack DB collection for fast lookups + // Populate global subscriptions collection from live query results useEffect(() => { - if (allSubscriptions.length > 0) { - upsertSubscriptionsInCollection(collections, allSubscriptions); + if (subscriptions && subscriptions.length > 0) { + upsertSubscriptionsInCollection(collections, subscriptions); } - }, [collections, allSubscriptions]); + }, [collections, subscriptions]); // Infinite scroll: observe sentinel element to load more useEffect(() => { @@ -89,7 +111,7 @@ export function TagSubscriptionList({ return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - if (subscriptionsQuery.isLoading) { + if (isLoading && !isReady) { return (
    {[1, 2].map((i) => ( @@ -101,13 +123,13 @@ export function TagSubscriptionList({ ); } - if (allSubscriptions.length === 0) { + if (!subscriptions || subscriptions.length === 0) { return null; } return (
      - {allSubscriptions.map((sub) => ( + {subscriptions.map((sub) => ( ))} {/* Sentinel for infinite scroll */} - {subscriptionsQuery.hasNextPage && ( -
    ); } diff --git a/src/lib/collections/entries.ts b/src/lib/collections/entries.ts index 751187e3..b68d49bb 100644 --- a/src/lib/collections/entries.ts +++ b/src/lib/collections/entries.ts @@ -1,31 +1,62 @@ /** - * Entries Collection - * - * Local-only collection of feed entries, populated incrementally from tRPC - * infinite query results. Components use tRPC useInfiniteQuery for paginated - * fetching (cursor-based, SSR-prefetchable), then populate the collection - * as pages load. - * - * The collection provides: - * - O(1) entry lookup by ID (collection.get(id)) instead of scanning pages - * - Centralized writes for mutations/SSE (writeUpdate) instead of updating - * every infinite query cache - * - Reactive read via useLiveQuery for components that need live updates - * - * Data flow: - * tRPC useInfiniteQuery → pages load → upsert into collection - * Mutations/SSE → writeUpdate on collection → components re-read merged data + * Entries Collections + * + * Two collection types: + * + * 1. **Global entries collection** (local-only): Singleton for SSE state updates, + * fallback lookups, and detail view overlays. Populated from view collection + * results and SSE events. + * + * 2. **View entries collection** (on-demand, query-backed): Per-view/filter + * collection that drives the entry list UI. Created per route/filter set, + * fetches pages from the server on demand as the user scrolls. + * Uses `useLiveInfiniteQuery` for rendering. */ import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient, InfiniteData } from "@tanstack/react-query"; import type { EntryListItem } from "./types"; +import type { EntryType } from "@/lib/queries/entries-list-input"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Entry list item with computed sort key for client-side ordering */ +export interface SortedEntryListItem extends EntryListItem { + _sortMs: number; +} + +/** Server response from entries.list */ +interface EntriesListResponse { + items: EntryListItem[]; + nextCursor?: string; +} + +/** Filter params baked into a view collection */ +export interface EntriesViewFilters { + subscriptionId?: string; + tagId?: string; + uncategorized?: boolean; + unreadOnly: boolean; + starredOnly?: boolean; + sortOrder: "newest" | "oldest"; + type?: EntryType; + limit: number; +} + +// --------------------------------------------------------------------------- +// Global entries collection (local-only) +// --------------------------------------------------------------------------- /** - * Creates the entries collection as a local-only store. + * Creates the global entries collection as a local-only store. * - * Populated incrementally from tRPC infinite query results (similar to - * how subscriptions collection is populated from TagSubscriptionList). - * Mutations and SSE events write directly to the collection. + * Used for: + * - SSE `entry_state_changed` / `entry_updated` writes + * - `EntryContentFallback` and `EntryListFallback` lookups + * - Cross-view state that persists across route changes */ export function createEntriesCollection() { return createCollection( @@ -37,3 +68,146 @@ export function createEntriesCollection() { } export type EntriesCollection = ReturnType; + +// --------------------------------------------------------------------------- +// View entries collection (on-demand, query-backed) +// --------------------------------------------------------------------------- + +/** + * Generates a stable string key for a filter combination. + * Used as the collection ID suffix and for cache key matching. + */ +export function stableFilterKey(filters: EntriesViewFilters): string { + return JSON.stringify([ + filters.subscriptionId ?? null, + filters.tagId ?? null, + filters.uncategorized ?? null, + filters.unreadOnly, + filters.starredOnly ?? null, + filters.sortOrder, + filters.type ?? null, + filters.limit, + ]); +} + +/** + * Checks the React Query cache for SSR-prefetched entries.list data. + * + * The server prefetches via `trpc.entries.list.prefetchInfinite(input)` which + * stores data under a key where `cursor` and `direction` are stripped by tRPC: + * [["entries", "list"], { input: { ...inputWithoutCursorAndDirection }, type: "infinite" }] + * + * We look this up to avoid a redundant first-page fetch. + */ +function checkSSRPrefetchCache( + queryClient: QueryClient, + filters: EntriesViewFilters +): EntriesListResponse | null { + // Build the tRPC infinite query key format. + // tRPC strips `cursor` and `direction` from infinite query keys + // (see getQueryKeyInternal in @trpc/react-query), so we must NOT include them. + const input = { + subscriptionId: filters.subscriptionId, + tagId: filters.tagId, + uncategorized: filters.uncategorized, + unreadOnly: filters.unreadOnly, + starredOnly: filters.starredOnly, + sortOrder: filters.sortOrder, + type: filters.type, + limit: filters.limit, + }; + const tRPCKey = [["entries", "list"], { input, type: "infinite" }]; + const data = queryClient.getQueryData>(tRPCKey); + if (data?.pages?.[0]) { + return data.pages[0]; + } + return null; +} + +/** + * Creates an on-demand entries collection for a specific view/filter set. + * + * The collection fetches pages from the server via cursor-based pagination, + * bridging TanStack DB's offset-based `loadSubset` to our cursor API. + * + * @param queryClient - The shared QueryClient instance + * @param fetchEntries - Function to fetch a page of entries from the server + * @param filters - The filter parameters for this view + */ +export function createViewEntriesCollection( + queryClient: QueryClient, + fetchEntries: (params: { + subscriptionId?: string; + tagId?: string; + uncategorized?: boolean; + unreadOnly: boolean; + starredOnly?: boolean; + sortOrder: "newest" | "oldest"; + type?: EntryType; + cursor?: string; + limit: number; + }) => Promise, + filters: EntriesViewFilters +) { + // Maps offset → nextCursor for bridging offset-based loading to cursor-based API + const cursorByOffset = new Map(); + + return createCollection( + queryCollectionOptions({ + id: `entries-view-${stableFilterKey(filters)}`, + syncMode: "on-demand" as const, + queryKey: ["entries-view", stableFilterKey(filters)], + queryClient, + getKey: (item: SortedEntryListItem) => item.id, + staleTime: Infinity, + queryFn: async (ctx) => { + const opts = ctx.meta?.loadSubsetOptions as { offset?: number; limit?: number } | undefined; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? filters.limit; + + // For the first page, check SSR-prefetched React Query cache + if (offset === 0) { + const prefetched = checkSSRPrefetchCache(queryClient, filters); + if (prefetched) { + if (prefetched.nextCursor) { + cursorByOffset.set(prefetched.items.length, prefetched.nextCursor); + } + return prefetched; + } + } + + // Map offset to cursor for subsequent pages + const cursor = offset > 0 ? cursorByOffset.get(offset) : undefined; + if (offset > 0 && !cursor) { + // No cursor for this offset — shouldn't happen with sequential loading + return { items: [] }; + } + + const result = await fetchEntries({ + subscriptionId: filters.subscriptionId, + tagId: filters.tagId, + uncategorized: filters.uncategorized, + unreadOnly: filters.unreadOnly, + starredOnly: filters.starredOnly, + sortOrder: filters.sortOrder, + type: filters.type, + cursor, + limit, + }); + + if (result.nextCursor) { + cursorByOffset.set(offset + result.items.length, result.nextCursor); + } + + return result; + }, + select: (data: EntriesListResponse): SortedEntryListItem[] => + data.items.map((item) => ({ + ...item, + _sortMs: new Date(item.publishedAt ?? item.fetchedAt).getTime(), + })), + }) + ); +} + +export type ViewEntriesCollection = ReturnType; diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index 50ef2523..6b5aa926 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -20,7 +20,11 @@ import type { QueryClient } from "@tanstack/react-query"; import { createSubscriptionsCollection, type SubscriptionsCollection } from "./subscriptions"; import { createTagsCollection, type TagsCollection } from "./tags"; -import { createEntriesCollection, type EntriesCollection } from "./entries"; +import { + createEntriesCollection, + type EntriesCollection, + type ViewEntriesCollection, +} from "./entries"; import { createCountsCollection, type CountsCollection } from "./counts"; import type { TagItem, UncategorizedCounts } from "./types"; @@ -34,16 +38,23 @@ export type { export type { SubscriptionsCollection } from "./subscriptions"; export type { TagsCollection } from "./tags"; export type { EntriesCollection } from "./entries"; +export type { ViewEntriesCollection } from "./entries"; export type { CountsCollection } from "./counts"; /** * All collections grouped together for convenient access. + * + * `activeViewCollection` is set by the SuspendingEntryList component to register + * the current view's on-demand collection. Mutations and SSE handlers write to it + * so changes propagate to the live query (in addition to the global entries collection). */ export interface Collections { subscriptions: SubscriptionsCollection; tags: TagsCollection; entries: EntriesCollection; counts: CountsCollection; + /** The active on-demand view collection, set by useViewEntriesCollection */ + activeViewCollection: ViewEntriesCollection | null; } /** @@ -78,5 +89,6 @@ export function createCollections( tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized, counts), entries: createEntriesCollection(), counts, + activeViewCollection: null, }; } diff --git a/src/lib/collections/subscriptions.ts b/src/lib/collections/subscriptions.ts index f753de28..3732eb04 100644 --- a/src/lib/collections/subscriptions.ts +++ b/src/lib/collections/subscriptions.ts @@ -1,27 +1,49 @@ /** - * Subscriptions Collection + * Subscriptions Collections * - * Local-only collection of user subscriptions. - * Populated incrementally as sidebar tag sections load pages via useInfiniteQuery, - * and by SSE/sync events (addSubscriptionToCollection, removeSubscriptionFromCollection). + * Two collection types: * - * Used for: - * - Fast synchronous lookups by ID (collection.get(id)) - * - Optimistic unread count updates (writeUpdate) - * - findCachedSubscription fallback + * 1. **Global subscriptions collection** (local-only): Singleton for SSE state updates, + * fast lookups by ID, and optimistic unread count updates. + * + * 2. **Tag subscriptions collection** (on-demand, query-backed): Per-tag/uncategorized + * collection that drives the sidebar subscription list UI. Created per tag section, + * fetches pages from the server on demand as the user scrolls. */ import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; import type { Subscription } from "./types"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Server response from subscriptions.list */ +interface SubscriptionsListResponse { + items: Subscription[]; + nextCursor?: string; +} + +/** Filter params baked into a tag subscription collection */ +export interface TagSubscriptionFilters { + tagId?: string; + uncategorized?: boolean; + limit: number; +} + +// --------------------------------------------------------------------------- +// Global subscriptions collection (local-only) +// --------------------------------------------------------------------------- + /** - * Creates the subscriptions collection as a local-only store. + * Creates the global subscriptions collection as a local-only store. * - * Unlike query-backed collections, this doesn't fetch data automatically. - * Data flows in from: - * 1. TagSubscriptionList useInfiniteQuery pages (via writeInsert/writeUpdate) - * 2. SSE subscription_created events (via addSubscriptionToCollection) - * 3. SSE subscription_deleted events (via removeSubscriptionFromCollection) + * Used for: + * - Fast synchronous lookups by ID (collection.get(id)) + * - Optimistic unread count updates + * - SSE subscription_created/deleted events */ export function createSubscriptionsCollection() { return createCollection( @@ -33,3 +55,74 @@ export function createSubscriptionsCollection() { } export type SubscriptionsCollection = ReturnType; + +// --------------------------------------------------------------------------- +// Tag subscriptions collection (on-demand, query-backed) +// --------------------------------------------------------------------------- + +/** + * Generates a stable string key for a tag subscription filter combination. + */ +export function stableTagFilterKey(filters: TagSubscriptionFilters): string { + return JSON.stringify([filters.tagId ?? null, filters.uncategorized ?? null, filters.limit]); +} + +/** + * Creates an on-demand subscriptions collection for a specific tag/uncategorized section. + * + * Same offset-to-cursor bridge pattern as entries view collections. + * + * @param queryClient - The shared QueryClient instance + * @param fetchSubscriptions - Function to fetch a page from the server + * @param filters - The filter parameters for this section + */ +export function createTagSubscriptionsCollection( + queryClient: QueryClient, + fetchSubscriptions: (params: { + tagId?: string; + uncategorized?: boolean; + cursor?: string; + limit: number; + }) => Promise, + filters: TagSubscriptionFilters +) { + const cursorByOffset = new Map(); + + return createCollection( + queryCollectionOptions({ + id: `subscriptions-tag-${stableTagFilterKey(filters)}`, + syncMode: "on-demand" as const, + queryKey: ["subscriptions-tag", stableTagFilterKey(filters)], + queryClient, + getKey: (item: Subscription) => item.id, + staleTime: Infinity, + queryFn: async (ctx) => { + const opts = ctx.meta?.loadSubsetOptions as { offset?: number; limit?: number } | undefined; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? filters.limit; + + // Map offset to cursor for subsequent pages + const cursor = offset > 0 ? cursorByOffset.get(offset) : undefined; + if (offset > 0 && !cursor) { + return { items: [] }; + } + + const result = await fetchSubscriptions({ + tagId: filters.tagId, + uncategorized: filters.uncategorized, + cursor, + limit, + }); + + if (result.nextCursor) { + cursorByOffset.set(offset + result.items.length, result.nextCursor); + } + + return result; + }, + select: (data: SubscriptionsListResponse): Subscription[] => data.items, + }) + ); +} + +export type TagSubscriptionsCollection = ReturnType; diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index 38ec8b09..41a2c7b0 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -354,14 +354,15 @@ export function adjustUncategorizedFeedCountInCollection( } // ============================================================================ -// Entry Collection Writes (local-only) +// Entry Collection Writes (global local-only + active view collection) // ============================================================================ /** - * Updates the read status for entries in the collection. - * No-ops for entries not currently in the collection. - * This is O(1) per entry (by key lookup) — replaces the O(n) page scanning - * in the old entry-cache.ts. + * Updates the read status for entries in both the global entries collection + * and the active view collection (if any). + * + * Global collection: local-only, uses collection.update() + * View collection: query-backed, uses collection.utils.writeUpdate() */ export function updateEntryReadInCollection( collections: Collections | null, @@ -377,10 +378,20 @@ export function updateEntryReadInCollection( }); } } + + // Also update the active view collection so useLiveInfiniteQuery picks up changes + const viewCol = collections.activeViewCollection; + if (viewCol) { + const updates = entryIds.filter((id) => viewCol.has(id)).map((id) => ({ id, read })); + if (updates.length > 0) { + viewCol.utils.writeUpdate(updates); + } + } } /** - * Updates the starred status for an entry in the collection. + * Updates the starred status for an entry in both the global entries collection + * and the active view collection (if any). */ export function updateEntryStarredInCollection( collections: Collections | null, @@ -394,10 +405,17 @@ export function updateEntryStarredInCollection( draft.starred = starred; }); } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, starred }); + } } /** - * Updates the score fields for an entry in the collection. + * Updates the score fields for an entry in both the global entries collection + * and the active view collection (if any). */ export function updateEntryScoreInCollection( collections: Collections | null, @@ -413,10 +431,17 @@ export function updateEntryScoreInCollection( draft.implicitScore = implicitScore; }); } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, score, implicitScore }); + } } /** - * Updates entry metadata (title, author, summary, url, publishedAt) in the collection. + * Updates entry metadata (title, author, summary, url, publishedAt) in both + * the global entries collection and the active view collection (if any). * Used for SSE entry_updated events. */ export function updateEntryMetadataInCollection( @@ -431,6 +456,12 @@ export function updateEntryMetadataInCollection( Object.assign(draft, metadata); }); } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, ...metadata }); + } } /** diff --git a/src/lib/hooks/useEntryNavigation.ts b/src/lib/hooks/useEntryNavigation.ts new file mode 100644 index 00000000..61c563d2 --- /dev/null +++ b/src/lib/hooks/useEntryNavigation.ts @@ -0,0 +1,94 @@ +/** + * Entry Navigation Context + * + * Shares entry list navigation state (next/previous entry IDs) between + * SuspendingEntryList (which knows the entry ordering) and + * UnifiedEntriesContent (which needs it for swipe gesture navigation + * in the entry content view). + * + * Uses a ref + callback pattern to avoid unnecessary re-renders: + * - SuspendingEntryList writes to the ref whenever entries change + * - UnifiedEntriesContent subscribes via onChange callback + */ + +"use client"; + +import { createContext, useContext, useCallback, useSyncExternalStore } from "react"; + +interface EntryNavigationState { + nextEntryId: string | undefined; + previousEntryId: string | undefined; +} + +interface EntryNavigationStore { + /** Get current navigation state */ + getSnapshot: () => EntryNavigationState; + /** Subscribe to state changes */ + subscribe: (listener: () => void) => () => void; + /** Update navigation state (called by SuspendingEntryList) */ + update: (state: EntryNavigationState) => void; +} + +const defaultState: EntryNavigationState = { + nextEntryId: undefined, + previousEntryId: undefined, +}; + +export function createEntryNavigationStore(): EntryNavigationStore { + let state = defaultState; + const listeners = new Set<() => void>(); + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + update: (newState) => { + // Only notify if state actually changed + if ( + state.nextEntryId !== newState.nextEntryId || + state.previousEntryId !== newState.previousEntryId + ) { + state = newState; + for (const listener of listeners) { + listener(); + } + } + }, + }; +} + +const EntryNavigationContext = createContext(null); + +export const EntryNavigationProvider = EntryNavigationContext.Provider; + +/** + * Subscribe to entry navigation state changes. + * Used by UnifiedEntriesContentInner for swipe gesture navigation. + */ +// No-op store used when context is not provided (avoids conditional hook call) +const noopStore: EntryNavigationStore = { + getSnapshot: () => defaultState, + subscribe: () => () => {}, + update: () => {}, +}; + +export function useEntryNavigationState(): EntryNavigationState { + const store = useContext(EntryNavigationContext) ?? noopStore; + return useSyncExternalStore(store.subscribe, store.getSnapshot, () => defaultState); +} + +/** + * Get the navigation state updater. + * Used by SuspendingEntryList to publish navigation state. + */ +export function useEntryNavigationUpdater(): (state: EntryNavigationState) => void { + const store = useContext(EntryNavigationContext); + return useCallback( + (state: EntryNavigationState) => { + store?.update(state); + }, + [store] + ); +} diff --git a/src/lib/hooks/useStableEntryList.ts b/src/lib/hooks/useStableEntryList.ts new file mode 100644 index 00000000..31b4dbc3 --- /dev/null +++ b/src/lib/hooks/useStableEntryList.ts @@ -0,0 +1,151 @@ +/** + * useStableEntryList Hook + * + * Provides display stability for entry lists: entries that were visible + * when the view loaded won't disappear when their state changes. + * + * Problem: If the user is viewing "unread only" and marks an entry as read, + * the live query's `where` clause filters it out and it vanishes. + * + * Solution: Track which entries have been rendered ("seen"). When an entry + * drops out of the live query results, look it up in the view collection + * (which still has it) and merge it back into the display list. + * + * The seen set resets on navigation (when filterKey changes = new collection). + * + * Implementation: Uses an external store (useSyncExternalStore) to track + * seen IDs, with a microtask-based recording mechanism to comply with + * React 19's strict rules against side effects during render. + */ + +"use client"; + +import { useMemo, useSyncExternalStore } from "react"; +import type { SortedEntryListItem, ViewEntriesCollection } from "@/lib/collections/entries"; + +/** + * External store that tracks seen entry IDs. + * All mutations happen outside render (via microtask or subscribe callback). + */ +function createSeenIdsStore() { + let seenIds = new Set(); + let pendingIds: string[] | null = null; + const listeners = new Set<() => void>(); + + function notify() { + for (const listener of listeners) { + listener(); + } + } + + return { + getSnapshot: () => seenIds, + subscribe: (callback: () => void) => { + listeners.add(callback); + // Flush any pending IDs when a subscriber is added + if (pendingIds) { + const ids = pendingIds; + pendingIds = null; + let changed = false; + for (const id of ids) { + if (!seenIds.has(id)) { + changed = true; + break; + } + } + if (changed) { + seenIds = new Set(seenIds); + for (const id of ids) { + seenIds.add(id); + } + // Schedule notification to avoid sync issues + queueMicrotask(notify); + } + } + return () => listeners.delete(callback); + }, + /** Schedule IDs to be recorded after render */ + scheduleSeen: (ids: string[]) => { + pendingIds = ids; + queueMicrotask(() => { + if (!pendingIds) return; + const toRecord = pendingIds; + pendingIds = null; + let changed = false; + for (const id of toRecord) { + if (!seenIds.has(id)) { + changed = true; + break; + } + } + if (changed) { + seenIds = new Set(seenIds); + for (const id of toRecord) { + seenIds.add(id); + } + notify(); + } + }); + }, + reset: () => { + pendingIds = null; + if (seenIds.size > 0) { + seenIds = new Set(); + notify(); + } + }, + }; +} + +/** + * Merges live query results with previously-seen entries to prevent + * entries from disappearing mid-session. + * + * @param liveEntries - Entries from useLiveInfiniteQuery (filtered by where clause) + * @param viewCollection - The view collection (has ALL fetched entries, even state-changed ones) + * @param filterKey - Changes on navigation, resetting the seen set + * @param sortDescending - Whether to sort newest first (true) or oldest first (false) + */ +export function useStableEntryList( + liveEntries: SortedEntryListItem[], + viewCollection: ViewEntriesCollection, + filterKey: string, + sortDescending: boolean +): SortedEntryListItem[] { + // Stable store per component instance — reset + recreate when filterKey changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate store on filter change + const store = useMemo(() => createSeenIdsStore(), [filterKey]); + + // Subscribe to seen IDs changes + const seenIds = useSyncExternalStore(store.subscribe, store.getSnapshot, () => new Set()); + + // Build the stable entry list by merging live entries with previously-seen ones + const stableEntries = useMemo(() => { + const liveIds = new Set(liveEntries.map((e) => e.id)); + const result: SortedEntryListItem[] = [...liveEntries]; + + // Add back previously-seen entries that no longer match the filter + for (const id of seenIds) { + if (!liveIds.has(id)) { + const entry = viewCollection.get(id) as SortedEntryListItem | undefined; + if (entry) { + result.push(entry); + } + } + } + + // Re-sort to maintain consistent ordering + if (sortDescending) { + result.sort((a, b) => b._sortMs - a._sortMs || b.id.localeCompare(a.id)); + } else { + result.sort((a, b) => a._sortMs - b._sortMs || a.id.localeCompare(b.id)); + } + + return result; + }, [liveEntries, viewCollection, seenIds, sortDescending]); + + // Schedule recording of current entries as seen (runs after render via microtask) + store.scheduleSeen(stableEntries.map((e) => e.id)); + + return stableEntries; +} diff --git a/src/lib/hooks/useTagSubscriptionsCollection.ts b/src/lib/hooks/useTagSubscriptionsCollection.ts new file mode 100644 index 00000000..70e93996 --- /dev/null +++ b/src/lib/hooks/useTagSubscriptionsCollection.ts @@ -0,0 +1,46 @@ +/** + * useTagSubscriptionsCollection Hook + * + * Creates and manages a per-tag on-demand subscriptions collection. + * The collection fetches pages from the server as the user scrolls + * in the sidebar tag section. + * + * A new collection is created when the tag/uncategorized filter changes. + */ + +"use client"; + +import { useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useVanillaClient } from "@/lib/trpc/vanilla-client"; +import { + createTagSubscriptionsCollection, + stableTagFilterKey, + type TagSubscriptionFilters, +} from "@/lib/collections/subscriptions"; + +/** + * Creates an on-demand subscriptions collection for a tag section. + * + * @param filters - Tag/uncategorized filter and page limit + * @returns The on-demand collection instance + */ +export function useTagSubscriptionsCollection(filters: TagSubscriptionFilters) { + const queryClient = useQueryClient(); + const vanillaClient = useVanillaClient(); + + const filterKey = useMemo(() => stableTagFilterKey(filters), [filters]); + + const collection = useMemo( + () => + createTagSubscriptionsCollection( + queryClient, + (params) => vanillaClient.subscriptions.list.query(params), + filters + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate on filter change + [filterKey, queryClient, vanillaClient] + ); + + return { collection, filterKey }; +} diff --git a/src/lib/hooks/useViewEntriesCollection.ts b/src/lib/hooks/useViewEntriesCollection.ts new file mode 100644 index 00000000..34d271fc --- /dev/null +++ b/src/lib/hooks/useViewEntriesCollection.ts @@ -0,0 +1,68 @@ +/** + * useViewEntriesCollection Hook + * + * Creates and manages a per-view on-demand entries collection. + * The collection fetches pages from the server as the user scrolls, + * bridging TanStack DB's offset-based loading to our cursor-based API. + * + * A new collection is created when the filter set changes (route navigation). + * The old collection is cleaned up when its subscriber count drops to 0. + * + * Also registers the collection as `activeViewCollection` on the Collections + * object so mutations and SSE handlers can write to it. + */ + +"use client"; + +import { useEffect, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useVanillaClient } from "@/lib/trpc/vanilla-client"; +import { useCollections } from "@/lib/collections/context"; +import { + createViewEntriesCollection, + stableFilterKey, + type EntriesViewFilters, +} from "@/lib/collections/entries"; + +/** + * Creates an on-demand entries collection for the current view filters. + * Recreates the collection when filters change (new route/filter combination). + * + * Registers the collection as `activeViewCollection` on the shared Collections + * object so mutations and SSE event handlers can propagate state changes. + * + * @param filters - The active filter set for this view + * @returns The on-demand collection instance and its filter key + */ +export function useViewEntriesCollection(filters: EntriesViewFilters) { + const queryClient = useQueryClient(); + const vanillaClient = useVanillaClient(); + const collections = useCollections(); + + // Stable key for memoization — only recreate collection when filters actually change + const filterKey = useMemo(() => stableFilterKey(filters), [filters]); + + const collection = useMemo( + () => + createViewEntriesCollection( + queryClient, + (params) => vanillaClient.entries.list.query(params), + filters + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate on filter change + [filterKey, queryClient, vanillaClient] + ); + + // Register as active view collection for mutations/SSE writes + useEffect(() => { + collections.activeViewCollection = collection; + return () => { + // Only clear if we're still the active one (avoid race with new mount) + if (collections.activeViewCollection === collection) { + collections.activeViewCollection = null; + } + }; + }, [collections, collection]); + + return { collection, filterKey }; +} diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 8c8fdadf..5ffa098c 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -17,6 +17,7 @@ import { getQueryClient } from "./query-client"; import type { AppRouter } from "@/server/trpc/root"; import { createCollections, type Collections } from "@/lib/collections"; import { CollectionsProvider } from "@/lib/collections/context"; +import { VanillaClientProvider, type VanillaClient } from "./vanilla-client"; /** * Check if an error is a tRPC UNAUTHORIZED error indicating invalid session. @@ -162,12 +163,15 @@ export function TRPCProvider({ children }: TRPCProviderProps) { ); // Create a vanilla tRPC client (for collection queryFn calls) - // and initialize TanStack DB collections - const [collections] = useState(() => { - const vanillaClient = createTRPCClient({ + // Exposed via VanillaClientProvider for use by on-demand collection hooks + const [vanillaClient] = useState(() => + createTRPCClient({ links: [createBatchLink()], - }); + }) + ); + // Initialize TanStack DB collections + const [collections] = useState(() => { const cols = createCollections(queryClient, { fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), }); @@ -196,7 +200,9 @@ export function TRPCProvider({ children }: TRPCProviderProps) { return ( - {children} + + {children} + ); diff --git a/src/lib/trpc/vanilla-client.ts b/src/lib/trpc/vanilla-client.ts new file mode 100644 index 00000000..d260e4a4 --- /dev/null +++ b/src/lib/trpc/vanilla-client.ts @@ -0,0 +1,31 @@ +/** + * Vanilla tRPC Client Context + * + * Provides the vanilla (non-React) tRPC client to components that need + * to make tRPC calls outside of React hooks — specifically for + * TanStack DB collection queryFn functions. + */ + +"use client"; + +import { createContext, useContext } from "react"; +import type { createTRPCClient } from "@trpc/client"; +import type { AppRouter } from "@/server/trpc/root"; + +export type VanillaClient = ReturnType>; + +const VanillaClientContext = createContext(null); + +/** + * Access the vanilla tRPC client from within components. + * Must be used inside TRPCProvider. + */ +export function useVanillaClient(): VanillaClient { + const client = useContext(VanillaClientContext); + if (!client) { + throw new Error("useVanillaClient must be used within TRPCProvider"); + } + return client; +} + +export const VanillaClientProvider = VanillaClientContext.Provider; From 375929dc04608e600109e4318738fd06794c28c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 04:56:12 +0000 Subject: [PATCH 09/31] Update docs for TanStack DB collections migration (Phases 4-6) - DESIGN.md: Add Client-Side State Management section documenting TanStack DB collections architecture, data flow, and key hooks. Update Real-time Updates to reference collection writes instead of React Query cache invalidation. - frontend-data-flow.d2: Rewrite diagram to show TanStack DB collections as primary state store, with React Query limited to SSR prefetch and entry detail views. Add collection writes, on-demand loading, display stability, and entry navigation. - sse-cache-updates.d2: Add entry_state_changed and tag_* event sources/handlers. Update cache operations to show collection writes instead of React Query cache helpers. Add entries router and tags service as event sources. - CLAUDE.md: Add collections/, hooks/, cache/, trpc/ to project structure under src/lib/. - Mark optimistic-updates-and-cache-management.md and query-normalization-plan.md as superseded by the collections migration. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 + docs/DESIGN.md | 66 ++++++- docs/diagrams/frontend-data-flow.d2 | 153 ++++++++++------ docs/diagrams/sse-cache-updates.d2 | 166 ++++++++++++------ ...optimistic-updates-and-cache-management.md | 4 +- docs/features/query-normalization-plan.md | 2 + 6 files changed, 284 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4a8c3b99..60cd4043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,10 @@ src/server/ plugins/ # Content source plugins (LessWrong, Google Docs, ArXiv, GitHub) mcp/ # MCP server src/lib/ # Shared utilities (client and server) + collections/ # TanStack DB collections (primary client state store) + hooks/ # Custom React hooks + cache/ # SSE event handlers and collection update operations + trpc/ # tRPC clients (React, vanilla, server, query-client) src/components/ # React components src/app/ # Next.js routes tests/unit/ # Pure logic tests (no mocks, no DB) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3cdb6154..bb9f414e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -20,10 +20,10 @@ For detailed feature designs, see the docs in `docs/features/`. Visual architecture diagrams are available in `docs/diagrams/`: -- **[frontend-data-flow.d2](diagrams/frontend-data-flow.d2)** - Delta-based state management with React Query +- **[frontend-data-flow.d2](diagrams/frontend-data-flow.d2)** - TanStack DB collections with on-demand data loading - **[backend-api.d2](diagrams/backend-api.d2)** - tRPC routers, services layer, and database - **[feed-fetcher.d2](diagrams/feed-fetcher.d2)** - Background job queue and feed processing pipeline -- **[sse-cache-updates.d2](diagrams/sse-cache-updates.d2)** - SSE event flow from backend to frontend cache updates +- **[sse-cache-updates.d2](diagrams/sse-cache-updates.d2)** - SSE event flow from backend to frontend collection updates To render these diagrams, use the [D2 CLI](https://d2lang.com/) or [D2 Playground](https://play.d2lang.com/). @@ -229,8 +229,8 @@ Lion Reader respects server Cache-Control headers, Retry-After directives, and H 2. Worker publishes to per-feed Redis channel: `PUBLISH feed:{feedId}:events {type, entryId, ...}` 3. SSE connections subscribe only to channels for feeds their user cares about 4. App server receives message, forwards to client -5. Client receives event, invalidates React Query cache -6. UI updates automatically +5. Client receives event, updates TanStack DB collections (counts, subscriptions, entries) +6. UI updates automatically via live queries ### Channel Design @@ -386,6 +386,64 @@ app/ - `components/app/` - App-level components - `components/ui/` - Generic UI primitives +### Client-Side State Management + +Lion Reader uses **TanStack DB collections** as the primary client-side state store, with React Query handling SSR prefetching and entry detail views. + +#### Collections Architecture (`src/lib/collections/`) + +TanStack DB collections provide reactive, live-queryable state. There are two types: + +- **Local-only collections**: Client-managed state updated by SSE events and mutations (subscriptions, entries, counts) +- **Query-backed on-demand collections**: Fetch pages from the server as the user scrolls, using an offset-to-cursor bridge to translate TanStack DB's offset-based loading to the cursor-based API (per-view entries, per-tag sidebar subscriptions) + +| Collection | Type | Purpose | +| ------------------------ | ------------------------ | -------------------------------------------- | +| **subscriptions** | Local-only | Sidebar subscription state and unread counts | +| **tags** | Query-backed (eager) | Tag list with feed/unread counts | +| **entries** (global) | Local-only | Lookup cache for SSE updates and detail view | +| **entries** (per-view) | Query-backed (on-demand) | Paginated entry list for the current view | +| **counts** | Local-only | Entry counts (all, starred, saved, etc.) | +| **activeViewCollection** | Reference | Points to current view's entry collection | + +#### Data Flow + +``` +Server ──(tRPC)──> React Query (SSR prefetch) ──> TanStack DB Collections + │ +SSE/Sync events ──────────────────────────────────> Collection writes + │ + Live Queries + │ + Components +``` + +1. **SSR prefetch**: React Query prefetches data on the server; collections check this cache to avoid redundant first-page fetches +2. **On-demand loading**: As users scroll, view collections fetch additional pages via the vanilla tRPC client +3. **Mutations**: Write to both the global collection and the `activeViewCollection` for immediate UI updates +4. **SSE events**: Update counts, subscriptions, and entry state directly in collections +5. **Display stability**: `useStableEntryList` merges live query results with previously-seen entries so items don't disappear when their state changes (e.g., marking read in "unread only" view) + +#### Key Hooks + +| Hook | Purpose | +| ------------------------------- | ------------------------------------------------------- | +| `useViewEntriesCollection` | Creates per-view on-demand entry collection | +| `useStableEntryList` | Prevents entries from disappearing on state change | +| `useEntryNavigation` | Shares next/prev entry IDs between list and detail view | +| `useTagSubscriptionsCollection` | Creates per-tag subscription collection for sidebar | +| `useEntryMutations` | Entry mutations with collection dual-writes | +| `useRealtimeUpdates` | SSE connection with collection-based event handling | + +#### React Query's Remaining Role + +React Query is still used for: + +- **SSR prefetch seeding** via `prefetchInfiniteQuery` / `prefetchQuery` +- **Entry detail view** (`entries.get`) cache +- **Import progress** tracking +- **Pagination invalidation** as a trigger for collection refresh + --- ## MCP Server diff --git a/docs/diagrams/frontend-data-flow.d2 b/docs/diagrams/frontend-data-flow.d2 index 652fb921..026ee02e 100644 --- a/docs/diagrams/frontend-data-flow.d2 +++ b/docs/diagrams/frontend-data-flow.d2 @@ -1,5 +1,6 @@ -# Frontend Data Flow - React Query with Direct Cache Updates -# Lion Reader uses React Query as the source of truth with helper functions for cache updates +# Frontend Data Flow - TanStack DB Collections with On-Demand Loading +# Lion Reader uses TanStack DB collections as the primary client state store, +# with React Query for SSR prefetch seeding and entry detail views. direction: down @@ -39,31 +40,43 @@ client: { style.fill: "#fff3e0" react_query: { - label: React Query Cache + label: React Query shape: cylinder style.fill: "#ffcc80" description: |md - **Server state cache** - - entries.list (infinite) - - entries.get - - entries.count - - subscriptions.list - - tags.list + **SSR prefetch + detail views** + - entries.get (detail view) + - SSR prefetch seeding + - imports.get/list | } - cache_helpers: { - label: Cache Helpers - shape: rectangle + collections: { + label: TanStack DB Collections + shape: cylinder style.fill: "#a5d6a7" description: |md - **src/lib/cache/** - - adjustSubscriptionUnreadCounts - - adjustTagUnreadCounts - - addSubscriptionToCache - - updateEntriesReadStatus + **Primary state store** + - subscriptions (local-only) + - tags (query-backed, eager) + - entries global (local-only) + - entries per-view (on-demand) + - counts (local-only) + | + } + + writes: { + label: Collection Writes + shape: rectangle + style.fill: "#c8e6c9" + + description: |md + **src/lib/collections/writes.ts** + - Dual-write: global + activeViewCollection + - Subscription/tag count adjustments + - Entry state updates (read, starred, score) | } } @@ -79,19 +92,31 @@ hooks: { useRealtimeUpdates: { label: useRealtimeUpdates shape: rectangle - tooltip: "Manages SSE connection with polling fallback" + tooltip: "SSE connection with polling fallback, delegates to collection writes" } useEntryMutations: { label: useEntryMutations shape: rectangle - tooltip: "Mutations with direct cache updates" + tooltip: "Mutations with dual-write to global + view collections" + } + + useViewEntriesCollection: { + label: useViewEntriesCollection + shape: rectangle + tooltip: "Creates per-view on-demand collection with offset-to-cursor bridge" } - useKeyboardShortcuts: { - label: useKeyboardShortcuts + useStableEntryList: { + label: useStableEntryList shape: rectangle - tooltip: "Keyboard navigation and entry selection" + tooltip: "Display stability - merges live entries with previously-seen entries" + } + + useEntryNavigation: { + label: useEntryNavigation + shape: rectangle + tooltip: "External store sharing next/prev entry IDs between list and detail" } } @@ -106,19 +131,19 @@ ui: { sidebar: { label: Sidebar shape: rectangle - tooltip: "Subscription list with unread counts" + tooltip: "Subscription list from per-tag on-demand collections" } unified_entries: { label: UnifiedEntriesContent shape: rectangle - tooltip: "Unified entry page with navigation and swipe support" + tooltip: "Unified entry page with swipe navigation" } suspending_list: { label: SuspendingEntryList shape: rectangle - tooltip: "Entry list with Suspense and keyboard navigation" + tooltip: "Entry list via useLiveInfiniteQuery over view collection" } entry_list: { @@ -135,55 +160,69 @@ ui: { } # ============================================================================== -# Connections - Initial Data Fetch +# Connections - SSR Prefetch # ============================================================================== -server.api -> client.react_query: "Initial fetch" { +server.api -> client.react_query: "SSR prefetch" { style.stroke: "#1976d2" style.stroke-width: 2 } +client.react_query -> client.collections: "Seed first page\n(avoid redundant fetch)" { + style.stroke: "#1976d2" + style.stroke-dash: 3 +} + # ============================================================================== -# Connections - Real-time Updates +# Connections - On-Demand Loading # ============================================================================== -server.sse -> hooks.useRealtimeUpdates: "SSE events\n(new_entry, subscription_*)" { - style.stroke: "#388e3c" +server.api -> client.collections: "On-demand pages\n(vanilla tRPC client)" { + style.stroke: "#00796b" style.stroke-width: 2 } -hooks.useRealtimeUpdates -> client.cache_helpers: "subscription_created\nsubscription_deleted" { +# ============================================================================== +# Connections - Real-time Updates +# ============================================================================== + +server.sse -> hooks.useRealtimeUpdates: "SSE events\n(new_entry, subscription_*)" { style.stroke: "#388e3c" + style.stroke-width: 2 } -hooks.useRealtimeUpdates -> client.react_query: "invalidate (new_entry)" { +hooks.useRealtimeUpdates -> client.writes: "Update collections\n(counts, subs, entries)" { style.stroke: "#388e3c" - style.stroke-dash: 3 } -client.cache_helpers -> client.react_query: "Direct cache updates" { +client.writes -> client.collections: "Write to collections" { style.stroke: "#00796b" style.stroke-width: 2 } # ============================================================================== -# Connections - Query Data Flow +# Connections - Collection Data Flow # ============================================================================== -client.react_query -> ui.unified_entries: "Paginated entries\n(non-suspending)" { +client.collections -> hooks.useViewEntriesCollection: "Create per-view\non-demand collection" { style.stroke: "#7b1fa2" } -client.react_query -> ui.suspending_list: "Paginated entries\n(suspending)" { +hooks.useViewEntriesCollection -> ui.suspending_list: "useLiveInfiniteQuery\n(reactive entries)" { + style.stroke: "#7b1fa2" + style.stroke-width: 2 +} + +ui.suspending_list -> hooks.useStableEntryList: "Merge with\npreviously-seen entries" { style.stroke: "#7b1fa2" } -ui.suspending_list -> ui.entry_list: "Entries with\nread/starred state" { +hooks.useStableEntryList -> ui.entry_list: "Stable entries\n(no disappearing)" { style.stroke: "#7b1fa2" style.stroke-width: 2 } -client.react_query -> ui.sidebar: "Subscriptions/tags\nwith counts" { +client.collections -> ui.sidebar: "Per-tag subscription\ncollections" { style.stroke: "#7b1fa2" } @@ -199,35 +238,39 @@ hooks.useEntryMutations -> server.api: "1. Server mutation" { style.stroke: "#f57c00" } -server.api -> hooks.useEntryMutations: "2. Response with\nsubscription context" { +server.api -> hooks.useEntryMutations: "2. Response" { style.stroke: "#f57c00" style.stroke-dash: 3 } -hooks.useEntryMutations -> client.cache_helpers: "3. Update counts\n(instant)" { +hooks.useEntryMutations -> client.writes: "3. Dual-write\n(global + view)" { style.stroke: "#f57c00" style.stroke-width: 2 } -hooks.useEntryMutations -> client.react_query: "4. Invalidate\nentry lists" { - style.stroke: "#f57c00" - style.stroke-dash: 3 -} - # ============================================================================== -# Connections - Navigation and Keyboard +# Connections - Entry Navigation # ============================================================================== -ui.suspending_list -> hooks.useKeyboardShortcuts: "Keyboard navigation" { +ui.suspending_list -> hooks.useEntryNavigation: "Publish next/prev\nentry IDs" { style.stroke: "#9e9e9e" style.stroke-dash: 3 } -ui.unified_entries -> ui.entry_content: "Entry view with\nswipe navigation" { +hooks.useEntryNavigation -> ui.unified_entries: "Consume next/prev\nfor swipe" { style.stroke: "#9e9e9e" style.stroke-dash: 3 } +ui.unified_entries -> ui.entry_content: "Entry detail view" { + style.stroke: "#9e9e9e" + style.stroke-dash: 3 +} + +client.react_query -> ui.entry_content: "entries.get\n(full content)" { + style.stroke: "#1976d2" +} + # ============================================================================== # Legend # ============================================================================== @@ -238,10 +281,10 @@ legend: { style.fill: "#fafafa" style.stroke: "#e0e0e0" - initial: "Blue: Initial data fetch" - realtime: "Green: Real-time updates (SSE)" - query: "Purple: Query data flow" - mutation: "Orange: User mutations" - cache: "Teal: Direct cache updates" - dashed: "Dashed: Invalidation" + initial: "Blue: SSR prefetch + detail views (React Query)" + ondemand: "Teal: On-demand loading + collection writes" + realtime: "Green: Real-time updates (SSE → collections)" + query: "Purple: Collection data flow to UI" + mutation: "Orange: User mutations (dual-write)" + nav: "Gray: Navigation state" } diff --git a/docs/diagrams/sse-cache-updates.d2 b/docs/diagrams/sse-cache-updates.d2 index 218915e0..0a58a03c 100644 --- a/docs/diagrams/sse-cache-updates.d2 +++ b/docs/diagrams/sse-cache-updates.d2 @@ -1,7 +1,8 @@ -# SSE Event Flow and Cache Update Architecture +# SSE Event Flow and Collection Update Architecture # # This diagram shows how events flow from backend origins through Redis pub/sub -# to the SSE endpoint, and how the frontend handles them to update caches. +# to the SSE endpoint, and how the frontend handles them to update TanStack DB +# collections and React Query caches. direction: down @@ -35,6 +36,13 @@ event-sources: Event Sources { saved-updated: publishEntryUpdated(savedFeedId, entryId) } + entries-router: Entries Router { + style.fill: "#e0e7ff" + tooltip: "src/server/trpc/routers/entries.ts" + + entry-state-changed: publishEntryStateChanged(userId, entryId, read, starred) + } + subscriptions-router: Subscriptions Router { style.fill: "#e0e7ff" tooltip: "src/server/trpc/routers/subscriptions.ts" @@ -43,6 +51,15 @@ event-sources: Event Sources { sub-deleted: publishSubscriptionDeleted(userId, feedId, subscriptionId) } + tags-service: Tags Service { + style.fill: "#e0e7ff" + tooltip: "src/server/services/tags.ts" + + tag-created: publishTagCreated(userId, tag) + tag-updated: publishTagUpdated(userId, tag) + tag-deleted: publishTagDeleted(userId, tagId) + } + job-handlers: Background Jobs { style.fill: "#e0e7ff" tooltip: "src/server/jobs/handlers.ts" @@ -81,8 +98,10 @@ redis: Redis Pub/Sub { Pattern: user:{userId}:events Events: + - entry_state_changed (entryId, read, starred) - subscription_created (full subscription + feed data) - subscription_deleted (subscriptionId) + - tag_created / tag_updated / tag_deleted - import_progress (per-feed progress) - import_completed (final counts) | @@ -146,16 +165,18 @@ frontend: Frontend (useRealtimeUpdates) { - connected - new_entry - entry_updated + - entry_state_changed - subscription_created - subscription_deleted + - tag_created / tag_updated / tag_deleted - import_progress - import_completed | } - handle-event: Event Handler { + handle-event: handleSyncEvent { style.fill: "#f5d0fe" - tooltip: "handleEvent callback" + tooltip: "src/lib/cache/event-handlers.ts - unified handler for SSE + polling" } polling-fallback: Polling Fallback { @@ -166,29 +187,29 @@ frontend: Frontend (useRealtimeUpdates) { - Poll sync.changes every 30s - Retry SSE every 60s - Uses granular cursors per entity type + - Same handleSyncEvent handler as SSE | } } # ============================================================================== -# CACHE UPDATE OPERATIONS +# COLLECTION UPDATE OPERATIONS # ============================================================================== -cache-ops: Cache Operations { +cache-ops: Collection & Cache Operations { style.fill: "#fff7ed" - tooltip: "src/lib/cache/operations.ts" + tooltip: "src/lib/cache/event-handlers.ts + operations.ts + collections/writes.ts" new-entry-handler: handleNewEntry { style.fill: "#fed7aa" updates: | - Updates (surgical, no refetch): - - subscriptions.list: unreadCount += 1 - - tags.list: unreadCount += 1 - - entries.count({}): unread += 1, total += 1 - - entries.count({ type: "saved" }): if feedType === "saved" + Collection updates (no refetch): + - subscriptions collection: unreadCount += 1 + - tags collection: unreadCount += 1 + - counts collection: unread += 1, total += 1 - Does NOT invalidate entries.list + Does NOT invalidate entry lists (entries appear on navigation) | } @@ -197,26 +218,37 @@ cache-ops: Cache Operations { style.fill: "#fed7aa" updates: | - Direct updates (metadata from event): + React Query (detail view): - entries.get: title, author, summary, url, publishedAt - - entries.list: same metadata fields (in-place) - Invalidations: - - entries.get({ id }): refetch full content + Collection (list view): + - entries collection: metadata update via + updateEntryMetadataInCollection() | } - sub-created-handler: handleSubscriptionCreated { + entry-state-handler: entry_state_changed { style.fill: "#fed7aa" updates: | - Direct updates (no refetch): - - subscriptions.list: add subscription - - tags.list: feedCount += 1, unreadCount += N - - entries.count({}): unread += N, total += N + React Query (detail view): + - entries.get: read, starred state + + Collection (list view): + - entries collection: read/starred via + updateEntryReadInCollection() + updateEntryStarredInCollection() + | + } + + sub-created-handler: handleSubscriptionCreated { + style.fill: "#fed7aa" - Targeted invalidations: - - subscriptions.list per-tag queries (affected tags only) + updates: | + Collection updates (no refetch): + - subscriptions collection: add subscription + - tags collection: feedCount += 1, unreadCount += N + - counts collection: unread += N, total += N | } @@ -224,25 +256,30 @@ cache-ops: Cache Operations { style.fill: "#fed7aa" updates: | - Direct updates (no refetch): - - subscriptions.list: remove subscription - - tags.list: feedCount -= 1, unreadCount -= N - - entries.count({}): unread -= N, total -= N + Collection updates (no refetch): + - subscriptions collection: remove subscription + - tags collection: feedCount -= 1, unreadCount -= N + - counts collection: unread -= N, total -= N - Invalidations: + React Query invalidation: - entries.list (entries should be filtered out) | } - import-handlers: import_progress / import_completed { + tag-handlers: tag_created / tag_updated / tag_deleted { style.fill: "#fed7aa" updates: | - import_progress: - - imports.get({ id }): invalidate - - imports.list: invalidate + Collection updates (no refetch): + - tags collection: add/update/remove tag + | + } - import_completed: + import-handlers: import_progress / import_completed { + style.fill: "#fed7aa" + + updates: | + React Query invalidations (no collection): - imports.get({ id }): invalidate - imports.list: invalidate (entry/subscription updates handled by individual events) @@ -270,12 +307,24 @@ event-sources.saved-service.saved-updated -> redis.feed-channels: entry_updated style.stroke: "#2563eb" } +event-sources.entries-router.entry-state-changed -> redis.user-channels: entry_state_changed { + style.stroke: "#7c3aed" +} event-sources.subscriptions-router.sub-created -> redis.user-channels: subscription_created { style.stroke: "#7c3aed" } event-sources.subscriptions-router.sub-deleted -> redis.user-channels: subscription_deleted { style.stroke: "#7c3aed" } +event-sources.tags-service.tag-created -> redis.user-channels: tag_created { + style.stroke: "#7c3aed" +} +event-sources.tags-service.tag-updated -> redis.user-channels: tag_updated { + style.stroke: "#7c3aed" +} +event-sources.tags-service.tag-deleted -> redis.user-channels: tag_deleted { + style.stroke: "#7c3aed" +} event-sources.job-handlers.import-sub-created -> redis.user-channels: subscription_created { style.stroke: "#7c3aed" } @@ -324,12 +373,18 @@ frontend.handle-event -> cache-ops.new-entry-handler: new_entry + feedType { frontend.handle-event -> cache-ops.entry-updated-handler: entry_updated { style.stroke: "#ea580c" } +frontend.handle-event -> cache-ops.entry-state-handler: entry_state_changed { + style.stroke: "#ea580c" +} frontend.handle-event -> cache-ops.sub-created-handler: subscription_created { style.stroke: "#ea580c" } frontend.handle-event -> cache-ops.sub-deleted-handler: subscription_deleted { style.stroke: "#ea580c" } +frontend.handle-event -> cache-ops.tag-handlers: tag_* { + style.stroke: "#ea580c" +} frontend.handle-event -> cache-ops.import-handlers: import_* { style.stroke: "#ea580c" } @@ -346,7 +401,7 @@ entry-types: Entry Type Differences { description: | - Published from entry-processor - Has subscriptionId - - Updates subscription + tag unread counts + - Updates subscription + tag collections (unread counts) | } @@ -355,7 +410,7 @@ entry-types: Entry Type Differences { description: | - Published from email inbound processor - Has subscriptionId - - Updates subscription + tag unread counts + - Updates subscription + tag collections (unread counts) | } @@ -364,49 +419,58 @@ entry-types: Entry Type Differences { description: | - Published from saved service/router - subscriptionId is NULL (no subscription row) - - Updates entries.count({ type: "saved" }) + - Updates counts collection (saved count) - Does NOT update subscription/tag counts | } } # ============================================================================== -# CACHE UPDATE STRATEGY NOTES +# UPDATE STRATEGY NOTES # ============================================================================== -strategy-notes: Cache Strategy { +strategy-notes: Update Strategy { style.fill: "#ecfccb" - direct-updates: Direct Updates (preferred) { + collection-writes: Collection Writes (preferred) { style.fill: "#d9f99d" description: | - Surgical updates avoid refetch: + Direct writes to TanStack DB collections: - Subscription unread counts - - Tag unread counts + - Tag unread/feed counts - Global entry counts - - Entry read/starred state - - Subscription list add/remove + - Entry read/starred/score state + - Subscription add/remove + - Tag add/update/remove + + Live queries react automatically. | } - invalidations: Invalidations (force refetch) { + react-query: React Query (detail + imports) { style.fill: "#d9f99d" description: | - Force refetch when necessary: - - Entry lists (too many filter combinations) - - Entry content on update - - Import progress (complex state) + entries.get setData for detail view: + - Entry metadata updates + - Entry state changes (read/starred) + + Invalidations for imports: + - imports.get/list on progress/completion | } design-rationale: Design Rationale { style.fill: "#d9f99d" description: | - entries.list is NOT invalidated on new_entry: - - Too many filter combinations (sub, tag, unread, starred) + Entry lists are NOT refreshed on new_entry: + - On-demand collections only fetch when scrolled - User won't see new entries until navigation - Unread counts still update (sidebar shows activity) - Keeps scrolling smooth without flicker + + Display stability via useStableEntryList: + - Previously-seen entries stay visible after state change + - Resets only on view navigation | } } diff --git a/docs/features/optimistic-updates-and-cache-management.md b/docs/features/optimistic-updates-and-cache-management.md index 81620b66..5a3627a6 100644 --- a/docs/features/optimistic-updates-and-cache-management.md +++ b/docs/features/optimistic-updates-and-cache-management.md @@ -1,10 +1,12 @@ # Optimistic Updates and Cache Management Optimization -**Status:** Draft +**Status:** Largely Superseded **Created:** 2026-01-10 **Author:** Claude **Related Issues:** N/A +> **Note (2026-02):** Most of the cache management concerns in this document have been superseded by the migration to TanStack DB collections (Phases 4-6). Sidebar counts, entry lists, subscription state, and tag state are now managed via TanStack DB collections with direct writes instead of React Query cache manipulation. React Query is only used for SSR prefetch seeding, entry detail views (`entries.get`), and import progress tracking. The SSE event handler (`handleSyncEvent`) now writes directly to collections. See `src/lib/collections/` for the current architecture. + ## Overview This document describes identified inefficiencies in our client-side cache management and proposes optimizations to reduce unnecessary network requests through optimistic updates and targeted cache mutations. diff --git a/docs/features/query-normalization-plan.md b/docs/features/query-normalization-plan.md index 3cdb36dc..e2a3f02e 100644 --- a/docs/features/query-normalization-plan.md +++ b/docs/features/query-normalization-plan.md @@ -1,5 +1,7 @@ # Query Normalization Design +> **Note (2026-02):** This plan has been superseded by the migration to TanStack DB collections (Phases 4-6). Entry data is now managed through TanStack DB collections rather than React Query cache entries, eliminating the cross-query duplication problem. See `src/lib/collections/` for the current architecture. + ## Problem Entry data is duplicated across multiple React Query cache entries: From 470877effdaba2713a1746148a70b1548bda6f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 05:06:44 +0000 Subject: [PATCH 10/31] Simplify Suspense boundaries after TanStack DB migration Remove redundant Suspense boundaries that duplicated logic already handled by TanStack DB collections. Title components now read directly from collections (subscriptions, tags) instead of using useSuspenseQuery with a fallback that did the same collection read. Entry list Suspense replaced by dynamic() loading prop since SuspendingEntryList handles its own loading state internally. - Delete EntryListFallback.tsx (143 lines of duplicated filter logic) - Replace SubscriptionTitle/TagTitle useSuspenseQuery with collection reads - Remove TitleFallback component (merged into EntryListTitle) - Add subscription upsert from validation query to populate collection - Move skeleton to dynamic() import loading prop Co-Authored-By: Claude Opus 4.6 --- src/components/entries/EntryListFallback.tsx | 143 ------------------ .../entries/UnifiedEntriesContent.tsx | 114 ++++---------- src/lib/collections/entries.ts | 2 +- 3 files changed, 27 insertions(+), 232 deletions(-) delete mode 100644 src/components/entries/EntryListFallback.tsx diff --git a/src/components/entries/EntryListFallback.tsx b/src/components/entries/EntryListFallback.tsx deleted file mode 100644 index 864f17a3..00000000 --- a/src/components/entries/EntryListFallback.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * EntryListFallback Component - * - * Smart Suspense fallback for entry lists. Tries to show cached entries - * from the TanStack DB entries collection (populated by SuspendingEntryList) - * while the actual query loads. Falls back to skeleton if no data available. - * - * Uses collection.state directly (non-reactive snapshot) rather than useLiveQuery - * to avoid SSR issues — useLiveQuery uses useSyncExternalStore without - * getServerSnapshot, which throws during hydration. Since this is a Suspense - * fallback that renders briefly, a non-reactive read is sufficient. - */ - -"use client"; - -import { useMemo } from "react"; -import { useCollections } from "@/lib/collections/context"; -import { EntryListItem } from "./EntryListItem"; -import { EntryListSkeleton } from "./EntryListSkeleton"; -import { EntryListLoadingMore } from "./EntryListStates"; - -/** - * Filter options for finding placeholder data. - */ -interface EntryListFilters { - subscriptionId?: string; - tagId?: string; - uncategorized?: boolean; - unreadOnly?: boolean; - starredOnly?: boolean; - sortOrder?: "newest" | "oldest"; - type?: "web" | "email" | "saved"; -} - -interface EntryListFallbackProps { - /** Filters for finding matching cached data */ - filters: EntryListFilters; - /** Number of skeleton items if no placeholder data */ - skeletonCount?: number; - /** Currently selected entry ID */ - selectedEntryId?: string | null; - /** Callback when entry is clicked (disabled during fallback) */ - onEntryClick?: (entryId: string) => void; -} - -/** - * Suspense fallback that shows cached entries when available. - * - * Uses the entries collection (populated by SuspendingEntryList) to find - * entries matching the requested filters. For tag/uncategorized filtering, - * uses the subscriptions collection to look up subscription-tag relationships. - * - * If no cached data matches, renders a skeleton. - */ -export function EntryListFallback({ - filters, - skeletonCount = 5, - selectedEntryId, - onEntryClick, -}: EntryListFallbackProps) { - const { entries: entriesCollection, subscriptions: subscriptionsCollection } = useCollections(); - - // Read non-reactive snapshots from collections — avoids useLiveQuery's - // useSyncExternalStore which crashes during SSR/hydration - const allEntries = Array.from(entriesCollection.state.values()); - const allSubscriptions = Array.from(subscriptionsCollection.state.values()); - - // Filter entries to match the requested view - const filteredEntries = useMemo(() => { - let result = allEntries; - - // Filter by subscription - if (filters.subscriptionId) { - result = result.filter((e) => e.subscriptionId === filters.subscriptionId); - } - - // Filter by tag — find subscriptions in the tag, then filter entries - if (filters.tagId) { - const subscriptionIdsInTag = new Set( - allSubscriptions - .filter((sub) => sub.tags.some((tag) => tag.id === filters.tagId)) - .map((sub) => sub.id) - ); - result = result.filter((e) => e.subscriptionId && subscriptionIdsInTag.has(e.subscriptionId)); - } - - // Filter by uncategorized — find subscriptions with no tags - if (filters.uncategorized) { - const uncategorizedSubscriptionIds = new Set( - allSubscriptions.filter((sub) => sub.tags.length === 0).map((sub) => sub.id) - ); - result = result.filter( - (e) => e.subscriptionId && uncategorizedSubscriptionIds.has(e.subscriptionId) - ); - } - - // Filter by starred - if (filters.starredOnly) { - result = result.filter((e) => e.starred); - } - - // Filter by unread - if (filters.unreadOnly) { - result = result.filter((e) => !e.read); - } - - // Filter by type - if (filters.type) { - result = result.filter((e) => e.type === filters.type); - } - - // Sort by ID (UUIDv7 is time-ordered) - const ascending = filters.sortOrder === "oldest"; - result.sort((a, b) => (ascending ? a.id.localeCompare(b.id) : b.id.localeCompare(a.id))); - - return result; - }, [allEntries, allSubscriptions, filters]); - - // No cached data - show skeleton - if (filteredEntries.length === 0) { - return ; - } - - // Show cached entries with a subtle loading indicator - return ( -
    - {filteredEntries.map((entry) => ( - - ))} - - {/* Show loading indicator at the bottom */} - -
    - ); -} diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index 1331c811..9b5db728 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -14,28 +14,28 @@ "use client"; -import { Suspense, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; import dynamic from "next/dynamic"; import { EntryPageLayout, TitleSkeleton, TitleText } from "./EntryPageLayout"; import { EntryContent } from "./EntryContent"; -import { EntryListFallback } from "./EntryListFallback"; +import { EntryListSkeleton } from "./EntryListSkeleton"; // SuspendingEntryList uses useLiveInfiniteQuery (TanStack DB) which calls useSyncExternalStore // without getServerSnapshot, causing SSR to crash. Disable SSR since the on-demand // collection is client-only state. const SuspendingEntryList = dynamic( () => import("./SuspendingEntryList").then((m) => m.SuspendingEntryList), - { ssr: false } + { ssr: false, loading: () => } ); import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { NotFoundCard } from "@/components/ui/not-found-card"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; import { useUrlViewPreferences } from "@/lib/hooks/useUrlViewPreferences"; -import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { type ViewType } from "@/lib/hooks/viewPreferences"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; +import { upsertSubscriptionsInCollection } from "@/lib/collections/writes"; import { createEntryNavigationStore, EntryNavigationProvider, @@ -205,61 +205,13 @@ function useRouteInfo(): RouteInfo { } /** - * Title component for subscription pages. - * Uses useSuspenseQuery so it suspends until data is available. - */ -function SubscriptionTitle({ subscriptionId }: { subscriptionId: string }) { - const [subscription] = trpc.subscriptions.get.useSuspenseQuery({ id: subscriptionId }); - if (!subscription) { - // This shouldn't happen with suspense, but handle it gracefully - return Untitled Feed; - } - return ( - {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} - ); -} - -/** - * Title component for tag pages. - * Uses useSuspenseQuery so it suspends until data is available. - */ -function TagTitle({ tagId }: { tagId: string }) { - const [tagsData] = trpc.tags.list.useSuspenseQuery(); - const tag = tagsData?.items.find((t) => t.id === tagId); - return {tag?.name ?? "Unknown Tag"}; -} - -/** - * Title component that handles all route types. - * Static titles render immediately; dynamic titles suspend until data loads. + * Title component that reads from collections for instant display. + * Falls back to skeleton if the data isn't in the collection yet. */ function EntryListTitle({ routeInfo }: { routeInfo: RouteInfo }) { - // Static title - render immediately - if (routeInfo.title !== null) { - return {routeInfo.title}; - } - - // Subscription title - suspends until subscription data loads - if (routeInfo.subscriptionId) { - return ; - } - - // Tag title - suspends until tags data loads - if (routeInfo.tagId) { - return ; - } - - return All Items; -} - -/** - * Smart title fallback that tries to show cached title instead of skeleton. - * Used as the Suspense fallback for the title slot. - */ -function TitleFallback({ routeInfo }: { routeInfo: RouteInfo }) { const collections = useCollections(); - // Static title - render immediately (shouldn't suspend anyway, but handle it) + // Static title - render immediately if (routeInfo.title !== null) { return {routeInfo.title}; } @@ -289,25 +241,31 @@ function TitleFallback({ routeInfo }: { routeInfo: RouteInfo }) { /** * Inner content component that renders based on route. - * Entry content and entry list have independent Suspense boundaries. + * Titles read from TanStack DB collections; entry list handles its own loading. */ function UnifiedEntriesContentInner() { const routeInfo = useRouteInfo(); const { showUnreadOnly } = useUrlViewPreferences(); const { openEntryId, setOpenEntryId, closeEntry } = useEntryUrlState(); - // Get query input based on current URL - const queryInput = useEntriesListInput(); - // Navigation state published by SuspendingEntryList via useEntryNavigationUpdater const { nextEntryId, previousEntryId } = useEntryNavigationState(); - // Fetch subscription data for validation + const collections = useCollections(); + + // Fetch subscription data for validation and to populate the collection for title display const subscriptionQuery = trpc.subscriptions.get.useQuery( { id: routeInfo.subscriptionId ?? "" }, { enabled: !!routeInfo.subscriptionId } ); + // Upsert subscription into collection so the title renders from the collection + useEffect(() => { + if (subscriptionQuery.data) { + upsertSubscriptionsInCollection(collections, [subscriptionQuery.data]); + } + }, [collections, subscriptionQuery.data]); + // Fetch tag data for validation and empty message customization const tagsQuery = trpc.tags.list.useQuery(undefined, { enabled: !!routeInfo.tagId, @@ -384,12 +342,8 @@ function UnifiedEntriesContentInner() { ); } - // Title has its own Suspense boundary with smart fallback that uses cache - const titleSlot = ( - }> - - - ); + // Title reads directly from collections - no Suspense needed + const titleSlot = ; // Entry content - has its own internal Suspense boundary const entryContentSlot = openEntryId ? ( @@ -404,30 +358,14 @@ function UnifiedEntriesContentInner() { /> ) : null; - // Entry list - has its own Suspense boundary + // Entry list - SuspendingEntryList handles its own loading state; + // the dynamic() import's loading prop covers the chunk load. const entryListSlot = ( - + - - + /> ); return ( diff --git a/src/lib/collections/entries.ts b/src/lib/collections/entries.ts index b68d49bb..48a828e4 100644 --- a/src/lib/collections/entries.ts +++ b/src/lib/collections/entries.ts @@ -55,7 +55,7 @@ export interface EntriesViewFilters { * * Used for: * - SSE `entry_state_changed` / `entry_updated` writes - * - `EntryContentFallback` and `EntryListFallback` lookups + * - `EntryContentFallback` lookups * - Cross-view state that persists across route changes */ export function createEntriesCollection() { From 4f3d4940bcbe1d720271ed2ebd21a9ec610f0fb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 05:20:59 +0000 Subject: [PATCH 11/31] Fix title not loading on direct page load for subscription/tag pages The title component was using non-reactive collection.get() calls which return a snapshot but don't trigger re-renders when the collection updates. On direct page load the collection is empty, so the skeleton rendered and never updated. Fix: extract EntryListTitle into its own component using useLiveQuery with findOne() for reactive lookups. When the subscription/tag data arrives in the collection (via SSR prefetch hydration or the validation query's upsert), useLiveQuery automatically re-renders with the title. Loaded via dynamic() with ssr: false since useLiveQuery uses useSyncExternalStore without getServerSnapshot. Co-Authored-By: Claude Opus 4.6 --- src/components/entries/EntryListTitle.tsx | 75 +++++++++++++++++++ .../entries/UnifiedEntriesContent.tsx | 45 ++--------- 2 files changed, 83 insertions(+), 37 deletions(-) create mode 100644 src/components/entries/EntryListTitle.tsx diff --git a/src/components/entries/EntryListTitle.tsx b/src/components/entries/EntryListTitle.tsx new file mode 100644 index 00000000..329b73f7 --- /dev/null +++ b/src/components/entries/EntryListTitle.tsx @@ -0,0 +1,75 @@ +/** + * EntryListTitle Component + * + * Reactively reads subscription/tag titles from TanStack DB collections. + * Uses useLiveQuery with findOne() so the title re-renders automatically + * when the data appears in the collection (e.g., after SSR prefetch + * hydrates or a validation query populates it). + * + * Loaded via dynamic() with ssr: false because useLiveQuery uses + * useSyncExternalStore without getServerSnapshot. + */ + +"use client"; + +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { TitleSkeleton, TitleText } from "./EntryPageLayout"; +import { useCollections } from "@/lib/collections/context"; + +interface EntryListTitleProps { + routeInfo: { + title: string | null; + subscriptionId?: string; + tagId?: string; + }; +} + +export function EntryListTitle({ routeInfo }: EntryListTitleProps) { + const { subscriptions, tags } = useCollections(); + + // Reactive lookup — re-renders when the subscription appears in the collection + const { data: subscription } = useLiveQuery( + (q) => + q + .from({ s: subscriptions }) + .where(({ s }) => eq(s.id, routeInfo.subscriptionId ?? "")) + .findOne(), + [routeInfo.subscriptionId] + ); + + // Reactive lookup — re-renders when the tag appears in the collection + const { data: tag } = useLiveQuery( + (q) => + q + .from({ t: tags }) + .where(({ t }) => eq(t.id, routeInfo.tagId ?? "")) + .findOne(), + [routeInfo.tagId] + ); + + // Static title + if (routeInfo.title !== null) { + return {routeInfo.title}; + } + + // Subscription title + if (routeInfo.subscriptionId) { + if (subscription) { + return ( + {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} + ); + } + return ; + } + + // Tag title + if (routeInfo.tagId) { + if (tag) { + return {tag.name}; + } + return ; + } + + return All Items; +} diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index 9b5db728..4cb0de8d 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -17,7 +17,7 @@ import { useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; import dynamic from "next/dynamic"; -import { EntryPageLayout, TitleSkeleton, TitleText } from "./EntryPageLayout"; +import { EntryPageLayout, TitleSkeleton } from "./EntryPageLayout"; import { EntryContent } from "./EntryContent"; import { EntryListSkeleton } from "./EntryListSkeleton"; @@ -28,6 +28,12 @@ const SuspendingEntryList = dynamic( () => import("./SuspendingEntryList").then((m) => m.SuspendingEntryList), { ssr: false, loading: () => } ); +// EntryListTitle uses useLiveQuery for reactive collection reads, which also +// uses useSyncExternalStore without getServerSnapshot. Disable SSR. +const EntryListTitle = dynamic(() => import("./EntryListTitle").then((m) => m.EntryListTitle), { + ssr: false, + loading: () => , +}); import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { NotFoundCard } from "@/components/ui/not-found-card"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; @@ -204,41 +210,6 @@ function useRouteInfo(): RouteInfo { }, [pathname]); } -/** - * Title component that reads from collections for instant display. - * Falls back to skeleton if the data isn't in the collection yet. - */ -function EntryListTitle({ routeInfo }: { routeInfo: RouteInfo }) { - const collections = useCollections(); - - // Static title - render immediately - if (routeInfo.title !== null) { - return {routeInfo.title}; - } - - // Subscription title from collection (O(1) lookup) - if (routeInfo.subscriptionId) { - const subscription = collections.subscriptions.get(routeInfo.subscriptionId); - if (subscription) { - return ( - {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} - ); - } - return ; - } - - // Tag title from collection (O(1) lookup) - if (routeInfo.tagId) { - const tag = collections.tags.get(routeInfo.tagId); - if (tag) { - return {tag.name}; - } - return ; - } - - return All Items; -} - /** * Inner content component that renders based on route. * Titles read from TanStack DB collections; entry list handles its own loading. @@ -342,7 +313,7 @@ function UnifiedEntriesContentInner() { ); } - // Title reads directly from collections - no Suspense needed + // Title reactively reads from collections via useLiveQuery const titleSlot = ; // Entry content - has its own internal Suspense boundary From e703f2162d55f51fc0902279249c7ab20eb1f6a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 05:38:32 +0000 Subject: [PATCH 12/31] Switch PWA share target back to auto-closing save page (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focus-existing + postMessage approach from #483 doesn't work on Android — the app state still gets destroyed before showing the result. Revert to always redirecting URL shares to /save, which saves the article and auto-closes itself, providing more useful behavior. - Remove launch_handler from PWA manifest - Simplify service worker to redirect URL shares to /save instead of saving directly via API and redirecting back to the app - Remove share result query param handling, launchQueue consumer, and service worker message listener from RealtimeProvider Co-Authored-By: Claude Opus 4.6 --- src/app/manifest.ts | 12 +- src/components/layout/RealtimeProvider.tsx | 94 +------------- worker/index.ts | 140 ++------------------- 3 files changed, 11 insertions(+), 235 deletions(-) diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 88744384..c0d85dbf 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -10,13 +10,6 @@ export default function manifest(): MetadataRoute.Manifest { orientation: "portrait", background_color: "#ffffff", theme_color: "#f97316", - // When the PWA is launched (e.g., via share target), focus the existing window - // instead of navigating it. This prevents Android's share target from destroying - // the current app state. The service worker handles the shared data via fetch - // event and notifies the window via postMessage for the toast. - launch_handler: { - client_mode: ["focus-existing", "auto"], - }, icons: [ { src: "/android-chrome-192x192.png", @@ -42,9 +35,8 @@ export default function manifest(): MetadataRoute.Manifest { }, ], // Share Target API: allows other Android apps to share URLs and files to Lion Reader. - // With launch_handler focus-existing, the service worker intercepts the POST, - // saves URLs directly via API, and notifies the focused window via postMessage. - // Files are stored in IndexedDB and the window redirects to /save for processing. + // The service worker intercepts the POST and redirects to /save, which handles + // saving the article and auto-closes itself. share_target: { action: "/api/share", method: "POST" as const, diff --git a/src/components/layout/RealtimeProvider.tsx b/src/components/layout/RealtimeProvider.tsx index 4a96b409..ea62b34b 100644 --- a/src/components/layout/RealtimeProvider.tsx +++ b/src/components/layout/RealtimeProvider.tsx @@ -4,32 +4,16 @@ * Manages the SSE connection for real-time updates and optionally displays * a connection status indicator. * - * Also handles messages from the service worker (e.g., share target results) - * to show toast notifications. - * * This component should be used in the app layout to enable real-time updates * for authenticated users. */ "use client"; -import { type ReactNode, useEffect } from "react"; -import { toast } from "sonner"; -import { useSearchParams, useRouter } from "next/navigation"; +import { type ReactNode } from "react"; import { useRealtimeUpdates, type SyncCursors } from "@/lib/hooks/useRealtimeUpdates"; import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator"; -/** - * Message format from service worker for share target results. - */ -interface ShareResultMessage { - type: "share-result"; - success: boolean; - url?: string; - title?: string; - error?: string; -} - interface RealtimeProviderProps { /** * Child components to render. @@ -80,82 +64,6 @@ export function RealtimeProvider({ showStatusIndicator = true, }: RealtimeProviderProps) { const { status, reconnect } = useRealtimeUpdates(initialCursors); - const searchParams = useSearchParams(); - const router = useRouter(); - - // Check for share result query params (set by service worker redirect after - // Android share target). Android destroys the PWA's navigation state on share, - // so postMessage may not reach the new page. The SW passes the result via URL. - useEffect(() => { - const shared = searchParams.get("shared"); - if (!shared) return; - - if (shared === "saved") { - const title = searchParams.get("sharedTitle"); - toast.success("Article saved", { - description: title || undefined, - }); - } else if (shared === "error") { - const error = searchParams.get("sharedError"); - toast.error("Failed to save article", { - description: error || undefined, - }); - } - - // Clean up query params without a full navigation - const url = new URL(window.location.href); - url.searchParams.delete("shared"); - url.searchParams.delete("sharedTitle"); - url.searchParams.delete("sharedError"); - router.replace(url.pathname + url.search, { scroll: false }); - }, [searchParams, router]); - - // Set up launchQueue consumer for focus-existing launch_handler. - // When the PWA is already open and receives a share target launch, - // the browser focuses this window and enqueues a LaunchParams instead of - // navigating. The service worker's fetch handler does the actual saving - // and sends a postMessage (handled below). We just need to consume the - // launch event so the browser doesn't fall back to default behavior. - useEffect(() => { - if (!("launchQueue" in window)) return; - - ( - window as { - launchQueue: { setConsumer: (cb: (params: { targetURL?: string }) => void) => void }; - } - ).launchQueue.setConsumer(() => { - // The service worker handles the actual save via its fetch event - // handler and notifies us via postMessage. Nothing to do here. - }); - }, []); - - // Listen for messages from service worker (e.g., share target results) - useEffect(() => { - if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { - return; - } - - const handleMessage = (event: MessageEvent) => { - const data = event.data as ShareResultMessage | undefined; - - if (data?.type === "share-result") { - if (data.success) { - toast.success("Article saved", { - description: data.title || data.url, - }); - } else { - toast.error("Failed to save article", { - description: data.error || data.url, - }); - } - } - }; - - navigator.serviceWorker.addEventListener("message", handleMessage); - return () => { - navigator.serviceWorker.removeEventListener("message", handleMessage); - }; - }, []); return ( <> diff --git a/worker/index.ts b/worker/index.ts index cd10b3be..c5378829 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -6,18 +6,10 @@ * 1. Share Target API for URLs and files * 2. Offline fallback for navigation requests * - * For URL shares, the service worker saves directly via the API without - * showing any intermediate UI. Open tabs are notified via postMessage so - * the RealtimeProvider can show a toast. + * For URL shares, the service worker redirects to /save which handles + * saving the article and auto-closes itself. * - * With launch_handler: focus-existing in the manifest, Chrome on Android - * focuses the existing PWA window instead of navigating it. The service - * worker's fetch handler still fires for the POST, and the postMessage - * reaches the focused window. When the app is NOT already open, - * focus-existing falls back to navigate-new, and the service worker - * redirects to the app root with query params for the toast. - * - * File shares still redirect to /save because they require more complex processing. + * File shares store the file in IndexedDB and redirect to /save for processing. * * Note: The main caching of Next.js static assets is handled by next-pwa's * Workbox configuration in next.config.ts. This file only adds custom handlers. @@ -35,15 +27,6 @@ interface SWFetchEvent extends Event { respondWith(response: Response | Promise): void; } -// Message types for communicating with open tabs -interface ShareResultMessage { - type: "share-result"; - success: boolean; - url?: string; - title?: string; - error?: string; -} - // Helper to convert File/Blob to base64 async function fileToBase64(file: File | Blob): Promise { const arrayBuffer = await file.arrayBuffer(); @@ -55,70 +38,6 @@ async function fileToBase64(file: File | Blob): Promise { return btoa(binary); } -/** - * Notifies all open Lion Reader tabs about a share result. - * Uses postMessage to send the result to all controlled clients. - */ -async function notifyClients(message: ShareResultMessage): Promise { - const clients = await sw.clients.matchAll({ type: "window" }); - for (const client of clients) { - client.postMessage(message); - } -} - -/** - * Attempts to save a URL directly via the API without navigation. - * Returns the saved article title on success, or throws an error. - */ -async function saveUrlDirectly(url: string, origin: string): Promise<{ title: string | null }> { - const apiUrl = `${origin}/api/v1/saved`; - - const response = await fetch(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", // Include session cookie - body: JSON.stringify({ url }), - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - - if (response.status === 401) { - throw new Error("NOT_AUTHENTICATED"); - } - - throw new Error(data.error?.message || `HTTP ${response.status}`); - } - - const data = await response.json(); - return { title: data.article?.title || null }; -} - -/** - * Returns the best URL to redirect to after a share completes. - * Android's share target destroys the PWA's navigation state, so we - * redirect back into the app. We check existing clients for an app page - * URL to restore, falling back to the root. - */ -async function getRedirectUrl(origin: string): Promise { - const clients = await sw.clients.matchAll({ type: "window" }); - // Look for an existing client on an app page (not /api/share, /save, etc.) - for (const client of clients) { - const clientUrl = new URL(client.url); - if ( - clientUrl.origin === origin && - !clientUrl.pathname.startsWith("/api/") && - !clientUrl.pathname.startsWith("/save") && - !clientUrl.pathname.startsWith("/login") - ) { - return client.url; - } - } - return `${origin}/`; -} - // Handle share target POST requests sw.addEventListener("fetch", ((event: SWFetchEvent) => { const url = new URL(event.request.url); @@ -185,7 +104,7 @@ sw.addEventListener("fetch", ((event: SWFetchEvent) => { return Response.redirect(redirectUrl.toString(), 303); } else if (sharedUrl || sharedText) { - // URL was shared - try to save directly without navigation + // URL was shared - redirect to /save page which handles saving and auto-closes let urlToSave = sharedUrl; if (!urlToSave && sharedText) { @@ -204,53 +123,10 @@ sw.addEventListener("fetch", ((event: SWFetchEvent) => { return Response.redirect(errorUrl.toString(), 303); } - // Try to save directly via API (no navigation) - try { - const result = await saveUrlDirectly(urlToSave, url.origin); - - // Notify any existing tabs (may not be received if this is the only window) - await notifyClients({ - type: "share-result", - success: true, - url: urlToSave, - title: result.title || undefined, - }); - - // Redirect back into the app with share result in query params. - // The postMessage above may not be received because Android's share - // target destroys the current page, so we pass the result via URL - // for the app to show a toast after loading. - const redirectUrl = new URL(await getRedirectUrl(url.origin)); - redirectUrl.searchParams.set("shared", "saved"); - if (result.title) { - redirectUrl.searchParams.set("sharedTitle", result.title); - } - return Response.redirect(redirectUrl.toString(), 303); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - // If not authenticated, redirect to /save for login flow - if (errorMessage === "NOT_AUTHENTICATED") { - const saveUrl = new URL("/save", url.origin); - saveUrl.searchParams.set("url", urlToSave); - saveUrl.searchParams.set("shared", "true"); - return Response.redirect(saveUrl.toString(), 303); - } - - // Notify any existing tabs - await notifyClients({ - type: "share-result", - success: false, - url: urlToSave, - error: errorMessage, - }); - - // Redirect back into the app with error in query params - const redirectUrl = new URL(await getRedirectUrl(url.origin)); - redirectUrl.searchParams.set("shared", "error"); - redirectUrl.searchParams.set("sharedError", errorMessage); - return Response.redirect(redirectUrl.toString(), 303); - } + const saveUrl = new URL("/save", url.origin); + saveUrl.searchParams.set("url", urlToSave); + saveUrl.searchParams.set("shared", "true"); + return Response.redirect(saveUrl.toString(), 303); } else { // No file or URL found const errorUrl = new URL("/save", url.origin); From e92cb8a3e471aa0087fedca88b6ef62596d7c56a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:24:49 +0000 Subject: [PATCH 13/31] Remove redundant collection updates in markRead/setStarred onSuccess (#573) The onSuccess handlers were performing two rounds of collection updates: first through applyWinningStateToCache (timestamp-based merging), then unconditionally again. The second round was wasted work when allComplete was true, and could cause state flickering when false by bypassing the timestamp-based merging logic. Co-Authored-By: Claude Opus 4.6 --- src/lib/hooks/useEntryMutations.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 44696547..e90ad362 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -336,12 +336,6 @@ export function useEntryMutations(): UseEntryMutationsResult { } } - // Update entries in collection with server state (read + score) - for (const entry of data.entries) { - updateEntryReadInCollection(collections, [entry.id], entry.read); - updateEntryScoreInCollection(collections, entry.id, entry.score, entry.implicitScore); - } - // Update counts (always apply, not dependent on timestamp) setBulkCounts(collections, data.counts); }, @@ -430,15 +424,6 @@ export function useEntryMutations(): UseEntryMutationsResult { applyWinningStateToCache(data.entry.id, winningState); } - // Update entries collection with server state (starred + score) - updateEntryStarredInCollection(collections, data.entry.id, data.entry.starred); - updateEntryScoreInCollection( - collections, - data.entry.id, - data.entry.score, - data.entry.implicitScore - ); - // Update counts (always apply) setCounts(collections, data.counts); }, From e8d50ddd4dc73cc2021a3901590e5f9df18bbcee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:26:28 +0000 Subject: [PATCH 14/31] Remove unnecessary useMemo mapping in SuspendingEntryList (#577) Since TypeScript uses structural typing, the extra `_sortMs` property on SortedEntryListItem is harmless when passed to components expecting EntryListData. Remove the manual field-by-field mapping that stripped `_sortMs`, and pass stableEntries directly to avoid unnecessary object allocations on every render. Co-Authored-By: Claude Opus 4.6 --- .../entries/SuspendingEntryList.tsx | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index cc863888..c135ba3e 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -121,44 +121,23 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) } }, [collections, stableEntries]); - // Map to EntryListItem shape (strip _sortMs for downstream compatibility) - const entries = useMemo( - () => - stableEntries.map((entry) => ({ - id: entry.id, - feedId: entry.feedId, - subscriptionId: entry.subscriptionId, - type: entry.type, - url: entry.url, - title: entry.title, - author: entry.author, - summary: entry.summary, - publishedAt: entry.publishedAt, - fetchedAt: entry.fetchedAt, - read: entry.read, - starred: entry.starred, - feedTitle: entry.feedTitle, - siteName: entry.siteName, - })), - [stableEntries] - ); - // Compute next/previous entry IDs for keyboard navigation // Also compute how close we are to the pagination boundary const { nextEntryId, previousEntryId, distanceToEnd } = useMemo(() => { - if (!openEntryId || entries.length === 0) { + if (!openEntryId || stableEntries.length === 0) { return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; } - const currentIndex = entries.findIndex((e) => e.id === openEntryId); + const currentIndex = stableEntries.findIndex((e) => e.id === openEntryId); if (currentIndex === -1) { return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; } return { - nextEntryId: currentIndex < entries.length - 1 ? entries[currentIndex + 1].id : undefined, - previousEntryId: currentIndex > 0 ? entries[currentIndex - 1].id : undefined, - distanceToEnd: entries.length - 1 - currentIndex, + nextEntryId: + currentIndex < stableEntries.length - 1 ? stableEntries[currentIndex + 1].id : undefined, + previousEntryId: currentIndex > 0 ? stableEntries[currentIndex - 1].id : undefined, + distanceToEnd: stableEntries.length - 1 - currentIndex, }; - }, [openEntryId, entries]); + }, [openEntryId, stableEntries]); // Publish navigation state for swipe gestures in EntryContent const updateNavigation = useEntryNavigationUpdater(); @@ -247,7 +226,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) // Keyboard shortcuts const { selectedEntryId } = useKeyboardShortcuts({ - entries, + entries: stableEntries, onOpenEntry: setOpenEntryId, onClose: () => setOpenEntryId(null), isEntryOpen: !!openEntryId, @@ -292,7 +271,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) selectedEntryId={selectedEntryId} onToggleRead={handleToggleRead} onToggleStar={toggleStar} - externalEntries={entries} + externalEntries={stableEntries} externalQueryState={externalQueryState} emptyMessage={emptyMessage} /> From f5502e29dbe5be5b0ba1e3cb61eec19b97598fd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:28:45 +0000 Subject: [PATCH 15/31] Replace manual SSE event parsing with Zod schemas (#579) Create shared event schemas at src/lib/events/schemas.ts with Zod validation for all sync event types, replacing ~290 lines of manual typeof checks in parseEventData with a 5-line Zod safeParse call. The manually-defined TypeScript interfaces in event-handlers.ts are now inferred from the Zod schemas. Co-Authored-By: Claude Opus 4.6 --- src/lib/cache/event-handlers.ts | 149 +------------- src/lib/events/schemas.ts | 234 +++++++++++++++++++++ src/lib/hooks/useRealtimeUpdates.ts | 306 +--------------------------- 3 files changed, 248 insertions(+), 441 deletions(-) create mode 100644 src/lib/events/schemas.ts diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 347f5f44..b5034761 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -21,152 +21,9 @@ import { updateEntryMetadataInCollection, } from "@/lib/collections/writes"; -// ============================================================================ -// Event Types -// ============================================================================ - -/** - * Base fields present on all sync events. - * `updatedAt` is used for cursor tracking (ISO string from database updated_at). - */ -interface BaseSyncEvent { - timestamp: string; - updatedAt: string; -} - -/** - * new_entry event. - */ -interface NewEntryEvent extends BaseSyncEvent { - type: "new_entry"; - subscriptionId: string | null; - entryId: string; - feedType?: "web" | "email" | "saved"; -} - -/** - * entry_updated event. - */ -interface EntryUpdatedEvent extends BaseSyncEvent { - type: "entry_updated"; - subscriptionId: string | null; - entryId: string; - metadata: { - title: string | null; - author: string | null; - summary: string | null; - url: string | null; - publishedAt: string | null; - }; -} - -/** - * entry_state_changed event. - */ -interface EntryStateChangedEvent extends BaseSyncEvent { - type: "entry_state_changed"; - entryId: string; - read: boolean; - starred: boolean; -} - -/** - * subscription_created event. - */ -interface SubscriptionCreatedEvent extends BaseSyncEvent { - type: "subscription_created"; - subscriptionId: string; - feedId: string; - subscription: { - id: string; - feedId: string; - customTitle: string | null; - subscribedAt: string; - unreadCount: number; - tags: Array<{ id: string; name: string; color: string | null }>; - }; - feed: { - id: string; - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - description: string | null; - siteUrl: string | null; - }; -} - -/** - * subscription_deleted event. - */ -interface SubscriptionDeletedEvent extends BaseSyncEvent { - type: "subscription_deleted"; - subscriptionId: string; -} - -/** - * tag_created event. - */ -interface TagCreatedEvent extends BaseSyncEvent { - type: "tag_created"; - tag: { id: string; name: string; color: string | null }; -} - -/** - * tag_updated event. - */ -interface TagUpdatedEvent extends BaseSyncEvent { - type: "tag_updated"; - tag: { id: string; name: string; color: string | null }; -} - -/** - * tag_deleted event. - */ -interface TagDeletedEvent extends BaseSyncEvent { - type: "tag_deleted"; - tagId: string; -} - -/** - * import_progress event. - */ -interface ImportProgressEvent extends BaseSyncEvent { - type: "import_progress"; - importId: string; - feedUrl: string; - feedStatus: "imported" | "skipped" | "failed"; - imported: number; - skipped: number; - failed: number; - total: number; -} - -/** - * import_completed event. - */ -interface ImportCompletedEvent extends BaseSyncEvent { - type: "import_completed"; - importId: string; - imported: number; - skipped: number; - failed: number; - total: number; -} - -/** - * Union type for all sync events. - */ -export type SyncEvent = - | NewEntryEvent - | EntryUpdatedEvent - | EntryStateChangedEvent - | SubscriptionCreatedEvent - | SubscriptionDeletedEvent - | TagCreatedEvent - | TagUpdatedEvent - | TagDeletedEvent - | ImportProgressEvent - | ImportCompletedEvent; +// Re-export SyncEvent type from the shared schema +export type { SyncEvent } from "@/lib/events/schemas"; +import type { SyncEvent } from "@/lib/events/schemas"; // ============================================================================ // Event Handler diff --git a/src/lib/events/schemas.ts b/src/lib/events/schemas.ts new file mode 100644 index 00000000..ec5ce1d6 --- /dev/null +++ b/src/lib/events/schemas.ts @@ -0,0 +1,234 @@ +/** + * Shared Event Schemas + * + * Zod schemas for all sync event types used by both SSE and polling sync. + * These replace manual typeof checks in the SSE parser and manual TypeScript + * interfaces in the event handler. + * + * The server may include extra fields (userId, feedId) that aren't needed + * client-side. Using passthrough() on the discriminated union allows those + * fields to pass through without validation errors while we extract only + * what the client needs via the schema definitions. + */ + +import { z } from "zod"; + +// ============================================================================ +// Shared Sub-schemas +// ============================================================================ + +const feedTypeSchema = z.enum(["web", "email", "saved"]); + +const tagSchema = z.object({ + id: z.string(), + name: z.string(), + color: z.string().nullable(), +}); + +const entryMetadataSchema = z.object({ + title: z.string().nullable(), + author: z.string().nullable(), + summary: z.string().nullable(), + url: z.string().nullable(), + publishedAt: z.string().nullable(), +}); + +const subscriptionDataSchema = z.object({ + id: z.string(), + feedId: z.string(), + customTitle: z.string().nullable(), + subscribedAt: z.string(), + unreadCount: z.number(), + tags: z.array(tagSchema), +}); + +const feedDataSchema = z.object({ + id: z.string(), + type: feedTypeSchema, + url: z.string().nullable(), + title: z.string().nullable(), + description: z.string().nullable(), + siteUrl: z.string().nullable(), +}); + +// ============================================================================ +// Base Fields +// ============================================================================ + +/** + * Default timestamp generator for events that may not include a timestamp. + */ +const defaultTimestamp = () => new Date().toISOString(); + +// ============================================================================ +// Individual Event Schemas +// ============================================================================ + +const newEntryEventSchema = z + .object({ + type: z.literal("new_entry"), + subscriptionId: z.string().nullable(), + entryId: z.string(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + feedType: feedTypeSchema.optional(), + }) + .passthrough(); + +const entryUpdatedEventSchema = z + .object({ + type: z.literal("entry_updated"), + subscriptionId: z.string().nullable(), + entryId: z.string(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + metadata: entryMetadataSchema, + }) + .passthrough(); + +const entryStateChangedEventSchema = z + .object({ + type: z.literal("entry_state_changed"), + entryId: z.string(), + read: z.boolean(), + starred: z.boolean(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + }) + .passthrough(); + +const subscriptionCreatedEventSchema = z + .object({ + type: z.literal("subscription_created"), + subscriptionId: z.string(), + feedId: z.string(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + subscription: subscriptionDataSchema, + feed: feedDataSchema, + }) + .passthrough(); + +const subscriptionDeletedEventSchema = z + .object({ + type: z.literal("subscription_deleted"), + subscriptionId: z.string(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + }) + .passthrough(); + +const tagCreatedEventSchema = z + .object({ + type: z.literal("tag_created"), + tag: tagSchema, + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + }) + .passthrough(); + +const tagUpdatedEventSchema = z + .object({ + type: z.literal("tag_updated"), + tag: tagSchema, + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + }) + .passthrough(); + +const tagDeletedEventSchema = z + .object({ + type: z.literal("tag_deleted"), + tagId: z.string(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string(), + }) + .passthrough(); + +/** + * Import events use `timestamp` as fallback for `updatedAt` since + * the server doesn't always include `updatedAt` for import events. + */ +const importProgressEventSchema = z + .object({ + type: z.literal("import_progress"), + importId: z.string(), + feedUrl: z.string(), + feedStatus: z.enum(["imported", "skipped", "failed"]), + imported: z.number(), + skipped: z.number(), + failed: z.number(), + total: z.number(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string().optional(), + }) + .passthrough() + .transform((event) => ({ + ...event, + updatedAt: event.updatedAt ?? event.timestamp, + })); + +const importCompletedEventSchema = z + .object({ + type: z.literal("import_completed"), + importId: z.string(), + imported: z.number(), + skipped: z.number(), + failed: z.number(), + total: z.number(), + timestamp: z.string().default(defaultTimestamp), + updatedAt: z.string().optional(), + }) + .passthrough() + .transform((event) => ({ + ...event, + updatedAt: event.updatedAt ?? event.timestamp, + })); + +// ============================================================================ +// Discriminated Union +// ============================================================================ + +/** + * Schema for all sync events. Used to validate SSE event data and + * derive the SyncEvent TypeScript type. + * + * Note: We use z.union instead of z.discriminatedUnion because some + * member schemas use .transform(), which is not supported by + * discriminatedUnion. + */ +export const syncEventSchema = z.union([ + newEntryEventSchema, + entryUpdatedEventSchema, + entryStateChangedEventSchema, + subscriptionCreatedEventSchema, + subscriptionDeletedEventSchema, + tagCreatedEventSchema, + tagUpdatedEventSchema, + tagDeletedEventSchema, + importProgressEventSchema, + importCompletedEventSchema, +]); + +// ============================================================================ +// Inferred Types +// ============================================================================ + +/** + * Union type for all sync events, inferred from the Zod schema. + */ +export type SyncEvent = z.infer; + +/** + * Individual event types for consumers that need to narrow on specific events. + */ +export type NewEntryEvent = z.infer; +export type EntryUpdatedEvent = z.infer; +export type EntryStateChangedEvent = z.infer; +export type SubscriptionCreatedEvent = z.infer; +export type SubscriptionDeletedEvent = z.infer; +export type TagCreatedEvent = z.infer; +export type TagUpdatedEvent = z.infer; +export type TagDeletedEvent = z.infer; +export type ImportProgressEvent = z.infer; +export type ImportCompletedEvent = z.infer; diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index 88920748..fe7fc682 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -18,6 +18,7 @@ import { useEffect, useRef, useCallback, useState } from "react"; import { trpc } from "@/lib/trpc/client"; import { useCollections } from "@/lib/collections/context"; import { handleSyncEvent, type SyncEvent } from "@/lib/cache/event-handlers"; +import { syncEventSchema } from "@/lib/events/schemas"; /** * Sync cursors for each entity type. @@ -97,306 +98,18 @@ const SSE_RETRY_INTERVAL_MS = 60_000; // SSE Event Parsing // ============================================================================ -/** - * SSE events include extra fields from the server (userId, feedId) that - * aren't part of the SyncEvent type. The parser extracts the SyncEvent-compatible - * fields and validates the structure. - */ - /** * Parses SSE event data from a JSON string into a SyncEvent. * Returns null if the data is invalid or doesn't match a known event type. + * + * Uses the shared Zod schema for validation, replacing ~290 lines of manual + * typeof checks. The schema handles defaults (e.g., timestamp) and + * passthrough of extra server fields (e.g., userId, feedId). */ function parseEventData(data: string): SyncEvent | null { try { - const parsed: unknown = JSON.parse(data); - - if (typeof parsed !== "object" || parsed === null || !("type" in parsed)) { - return null; - } - - const event = parsed as Record; - - // Handle new_entry events - if ( - event.type === "new_entry" && - (typeof event.subscriptionId === "string" || event.subscriptionId === null) && - typeof event.entryId === "string" && - typeof event.updatedAt === "string" - ) { - return { - type: "new_entry" as const, - subscriptionId: event.subscriptionId as string | null, - entryId: event.entryId, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - feedType: - typeof event.feedType === "string" && ["web", "email", "saved"].includes(event.feedType) - ? (event.feedType as "web" | "email" | "saved") - : undefined, - }; - } - - // Handle entry_updated events (includes metadata for cache updates) - if ( - event.type === "entry_updated" && - (typeof event.subscriptionId === "string" || event.subscriptionId === null) && - typeof event.entryId === "string" && - typeof event.updatedAt === "string" && - typeof event.metadata === "object" && - event.metadata !== null - ) { - const metadata = event.metadata as Record; - // Validate metadata structure - if ( - (metadata.title === null || typeof metadata.title === "string") && - (metadata.author === null || typeof metadata.author === "string") && - (metadata.summary === null || typeof metadata.summary === "string") && - (metadata.url === null || typeof metadata.url === "string") && - (metadata.publishedAt === null || typeof metadata.publishedAt === "string") - ) { - return { - type: "entry_updated" as const, - subscriptionId: event.subscriptionId as string | null, - entryId: event.entryId, - timestamp: - typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - metadata: { - title: metadata.title as string | null, - author: metadata.author as string | null, - summary: metadata.summary as string | null, - url: metadata.url as string | null, - publishedAt: metadata.publishedAt as string | null, - }, - }; - } - } - - // Handle entry_state_changed events - if ( - event.type === "entry_state_changed" && - typeof event.entryId === "string" && - typeof event.read === "boolean" && - typeof event.starred === "boolean" && - typeof event.updatedAt === "string" - ) { - return { - type: event.type, - entryId: event.entryId, - read: event.read, - starred: event.starred, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - }; - } - - // Handle subscription_created events - if ( - event.type === "subscription_created" && - typeof event.subscriptionId === "string" && - typeof event.feedId === "string" && - typeof event.updatedAt === "string" && - typeof event.subscription === "object" && - event.subscription !== null && - typeof event.feed === "object" && - event.feed !== null - ) { - const sub = event.subscription as Record; - const feed = event.feed as Record; - - // Validate subscription structure - if ( - typeof sub.id !== "string" || - typeof sub.feedId !== "string" || - (sub.customTitle !== null && typeof sub.customTitle !== "string") || - typeof sub.subscribedAt !== "string" || - typeof sub.unreadCount !== "number" || - !Array.isArray(sub.tags) - ) { - return null; - } - - // Validate feed structure - if ( - typeof feed.id !== "string" || - (feed.type !== "web" && feed.type !== "email" && feed.type !== "saved") || - (feed.url !== null && typeof feed.url !== "string") || - (feed.title !== null && typeof feed.title !== "string") || - (feed.description !== null && typeof feed.description !== "string") || - (feed.siteUrl !== null && typeof feed.siteUrl !== "string") - ) { - return null; - } - - return { - type: event.type, - subscriptionId: event.subscriptionId, - feedId: event.feedId, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - subscription: { - id: sub.id, - feedId: sub.feedId, - customTitle: sub.customTitle as string | null, - subscribedAt: sub.subscribedAt, - unreadCount: sub.unreadCount, - tags: sub.tags as Array<{ id: string; name: string; color: string | null }>, - }, - feed: { - id: feed.id, - type: feed.type, - url: feed.url as string | null, - title: feed.title as string | null, - description: feed.description as string | null, - siteUrl: feed.siteUrl as string | null, - }, - }; - } - - // Handle subscription_deleted events - if ( - event.type === "subscription_deleted" && - typeof event.subscriptionId === "string" && - typeof event.updatedAt === "string" - ) { - return { - type: event.type, - subscriptionId: event.subscriptionId, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - }; - } - - // Handle tag_created events - if ( - event.type === "tag_created" && - typeof event.tag === "object" && - event.tag !== null && - typeof event.updatedAt === "string" - ) { - const tag = event.tag as Record; - if ( - typeof tag.id === "string" && - typeof tag.name === "string" && - (tag.color === null || typeof tag.color === "string") - ) { - return { - type: event.type, - tag: { - id: tag.id, - name: tag.name, - color: tag.color as string | null, - }, - timestamp: - typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - }; - } - } - - // Handle tag_updated events - if ( - event.type === "tag_updated" && - typeof event.tag === "object" && - event.tag !== null && - typeof event.updatedAt === "string" - ) { - const tag = event.tag as Record; - if ( - typeof tag.id === "string" && - typeof tag.name === "string" && - (tag.color === null || typeof tag.color === "string") - ) { - return { - type: event.type, - tag: { - id: tag.id, - name: tag.name, - color: tag.color as string | null, - }, - timestamp: - typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - }; - } - } - - // Handle tag_deleted events - if ( - event.type === "tag_deleted" && - typeof event.tagId === "string" && - typeof event.updatedAt === "string" - ) { - return { - type: event.type, - tagId: event.tagId, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - updatedAt: event.updatedAt, - }; - } - - // Handle import_progress events - if ( - event.type === "import_progress" && - typeof event.importId === "string" && - typeof event.feedUrl === "string" && - (event.feedStatus === "imported" || - event.feedStatus === "skipped" || - event.feedStatus === "failed") && - typeof event.imported === "number" && - typeof event.skipped === "number" && - typeof event.failed === "number" && - typeof event.total === "number" - ) { - return { - type: event.type, - importId: event.importId, - feedUrl: event.feedUrl, - feedStatus: event.feedStatus, - imported: event.imported, - skipped: event.skipped, - failed: event.failed, - total: event.total, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - // Import events don't have updatedAt from the server, use timestamp - updatedAt: - typeof event.updatedAt === "string" - ? event.updatedAt - : typeof event.timestamp === "string" - ? event.timestamp - : new Date().toISOString(), - }; - } - - // Handle import_completed events - if ( - event.type === "import_completed" && - typeof event.importId === "string" && - typeof event.imported === "number" && - typeof event.skipped === "number" && - typeof event.failed === "number" && - typeof event.total === "number" - ) { - return { - type: event.type, - importId: event.importId, - imported: event.imported, - skipped: event.skipped, - failed: event.failed, - total: event.total, - timestamp: typeof event.timestamp === "string" ? event.timestamp : new Date().toISOString(), - // Import events don't have updatedAt from the server, use timestamp - updatedAt: - typeof event.updatedAt === "string" - ? event.updatedAt - : typeof event.timestamp === "string" - ? event.timestamp - : new Date().toISOString(), - }; - } - - return null; + const result = syncEventSchema.safeParse(JSON.parse(data)); + return result.success ? result.data : null; } catch { return null; } @@ -446,6 +159,8 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda const shouldConnectRef = useRef(false); // Initialize with server-provided cursors (granular tracking per entity type) const cursorsRef = useRef(initialCursors); + // Ref for performSync to allow self-referencing without violating react-hooks/immutability + const performSyncRef = useRef<() => Promise>(); // State to trigger reconnection const [reconnectTrigger, setReconnectTrigger] = useState(0); @@ -565,7 +280,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // If there are more events, schedule another sync soon if (result.hasMore) { // Use setTimeout to avoid blocking - the next poll or manual sync will pick up more - setTimeout(() => performSync(), 100); + setTimeout(() => performSyncRef.current?.(), 100); } return cursorsRef.current; @@ -574,6 +289,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return null; } }, [utils, updateCursorForEvent, collections]); + performSyncRef.current = performSync; /** * Starts polling mode when SSE is unavailable. From db89836ecc1c7809d233bf7288d7cae0a0a879ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:29:51 +0000 Subject: [PATCH 16/31] Move uncategorized count sync out of tags select into query cache subscription (#578) The tags collection's `select` function was writing uncategorized counts to the counts collection as a side effect. Since `select` can be called multiple times by TanStack Query, this violated the expected contract that `select` is a pure transformation. Instead, use `queryClient.getQueryCache().subscribe()` to watch for tags.list query updates and sync uncategorized counts in response. Also seeds from SSR-prefetched data on initialization. The subscription is cleaned up when the TRPCProvider unmounts. Co-Authored-By: Claude Opus 4.6 --- src/lib/collections/index.ts | 93 +++++++++++++++++++++++++++++++++--- src/lib/collections/tags.ts | 38 ++++----------- src/lib/trpc/provider.tsx | 18 +++++-- 3 files changed, 108 insertions(+), 41 deletions(-) diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index 6b5aa926..292acf92 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -17,16 +17,21 @@ * (useLiveSuspenseQuery) */ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryCacheNotifyEvent } from "@tanstack/react-query"; import { createSubscriptionsCollection, type SubscriptionsCollection } from "./subscriptions"; -import { createTagsCollection, type TagsCollection } from "./tags"; +import { + createTagsCollection, + type TagsCollection, + TRPC_TAGS_LIST_KEY, + type TagsListResponse, +} from "./tags"; import { createEntriesCollection, type EntriesCollection, type ViewEntriesCollection, } from "./entries"; import { createCountsCollection, type CountsCollection } from "./counts"; -import type { TagItem, UncategorizedCounts } from "./types"; +import type { CountRecord, TagItem, UncategorizedCounts } from "./types"; export type { Subscription, @@ -68,27 +73,101 @@ export interface CollectionFetchers { }>; } +/** + * Result of creating collections, including a cleanup function + * for unsubscribing from query cache listeners. + */ +export interface CreateCollectionsResult { + collections: Collections; + /** Unsubscribe from query cache listeners. Call when collections are destroyed. */ + cleanup: () => void; +} + +/** + * Check if a query key matches the tRPC tags.list key. + */ +function isTagsListKey(queryKey: readonly unknown[]): boolean { + if (queryKey.length < 2) return false; + const path = queryKey[0]; + const meta = queryKey[1] as { type?: string } | undefined; + return ( + Array.isArray(path) && + path.length === 2 && + path[0] === TRPC_TAGS_LIST_KEY[0][0] && + path[1] === TRPC_TAGS_LIST_KEY[0][1] && + meta?.type === TRPC_TAGS_LIST_KEY[1].type + ); +} + +/** + * Write uncategorized counts from a tags.list response to the counts collection. + */ +function syncUncategorizedCounts(counts: CountsCollection, data: TagsListResponse): void { + const existing = counts.get("uncategorized"); + if (existing) { + counts.update("uncategorized", (draft: CountRecord) => { + draft.total = data.uncategorized.feedCount; + draft.unread = data.uncategorized.unreadCount; + }); + } else { + counts.insert({ + id: "uncategorized", + total: data.uncategorized.feedCount, + unread: data.uncategorized.unreadCount, + }); + } +} + /** * Creates all TanStack DB collections. * * Called once in the TRPCProvider when the QueryClient is available. * The fetcher functions bridge tRPC with the collection queryFn interface. * + * Sets up a query cache subscription to sync uncategorized counts from + * tags.list responses into the counts collection, keeping the tags + * collection's `select` function pure. + * * @param queryClient - The shared QueryClient instance * @param fetchers - Functions to fetch data from the tRPC API + * @returns Collections and a cleanup function to unsubscribe cache listeners */ export function createCollections( queryClient: QueryClient, fetchers: CollectionFetchers -): Collections { - // Create counts first since tags needs it for uncategorized counts +): CreateCollectionsResult { const counts = createCountsCollection(); - return { + const collections: Collections = { subscriptions: createSubscriptionsCollection(), - tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized, counts), + tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized), entries: createEntriesCollection(), counts, activeViewCollection: null, }; + + // Seed uncategorized counts from SSR-prefetched tags.list data + const prefetchedTagsData = queryClient.getQueryData( + TRPC_TAGS_LIST_KEY as unknown as readonly unknown[] + ); + if (prefetchedTagsData) { + syncUncategorizedCounts(counts, prefetchedTagsData); + } + + // Subscribe to query cache updates to sync uncategorized counts + // whenever tags.list data changes (fetches, refetches, SSE invalidations) + const unsubscribe = queryClient.getQueryCache().subscribe((event: QueryCacheNotifyEvent) => { + if ( + event.type === "updated" && + event.action.type === "success" && + isTagsListKey(event.query.queryKey) + ) { + const data = event.query.state.data as TagsListResponse | undefined; + if (data) { + syncUncategorizedCounts(counts, data); + } + } + }); + + return { collections, cleanup: unsubscribe }; } diff --git a/src/lib/collections/tags.ts b/src/lib/collections/tags.ts index 7aac7cdb..c8e0d2b9 100644 --- a/src/lib/collections/tags.ts +++ b/src/lib/collections/tags.ts @@ -8,18 +8,18 @@ * Uses the tRPC tags.list query key directly so it shares the same cache * entry as the SSR prefetch, avoiding a duplicate network fetch on load. * - * The tags.list API also returns uncategorized counts, which are stored - * in the counts collection under the "uncategorized" key. + * The tags.list API also returns uncategorized counts. These are synced + * to the counts collection via a query cache subscription set up in + * createCollections() (see index.ts), keeping `select` a pure transform. */ import { createCollection } from "@tanstack/react-db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import type { QueryClient } from "@tanstack/react-query"; -import type { CountsCollection } from "./counts"; import type { TagItem, UncategorizedCounts } from "./types"; /** Shape of the tRPC tags.list response */ -interface TagsListResponse { +export interface TagsListResponse { items: TagItem[]; uncategorized: UncategorizedCounts; } @@ -30,23 +30,20 @@ interface TagsListResponse { * Format: [path, { type }] where path is the split procedure path. * See @trpc/react-query getQueryKeyInternal for the key generation logic. */ -const TRPC_TAGS_LIST_KEY = [["tags", "list"], { type: "query" }] as const; +export const TRPC_TAGS_LIST_KEY = [["tags", "list"], { type: "query" }] as const; /** * Creates the tags collection backed by TanStack Query. * * Uses `select` to extract the `items` array from the tags.list response. - * Also writes uncategorized counts to the counts collection as a side effect - * of the select function (runs on every successful fetch/refetch). + * This is a pure transformation with no side effects. * * @param queryClient - The shared QueryClient instance * @param fetchTagsAndUncategorized - Function to fetch tags + uncategorized counts from the API - * @param countsCollection - The counts collection to write uncategorized counts to */ export function createTagsCollection( queryClient: QueryClient, - fetchTagsAndUncategorized: () => Promise, - countsCollection: CountsCollection + fetchTagsAndUncategorized: () => Promise ) { return createCollection( queryCollectionOptions({ @@ -56,25 +53,8 @@ export function createTagsCollection( queryFn: async () => { return await fetchTagsAndUncategorized(); }, - // Extract items array from the { items, uncategorized } response - select: (data: TagsListResponse) => { - // Side effect: write uncategorized counts to the counts collection - const existing = countsCollection.get("uncategorized"); - if (existing) { - countsCollection.update("uncategorized", (draft) => { - draft.total = data.uncategorized.feedCount; - draft.unread = data.uncategorized.unreadCount; - }); - } else { - countsCollection.insert({ - id: "uncategorized", - total: data.uncategorized.feedCount, - unread: data.uncategorized.unreadCount, - }); - } - - return data.items; - }, + // Pure transformation: extract items array from the { items, uncategorized } response + select: (data: TagsListResponse) => data.items, queryClient, getKey: (item: TagItem) => item.id, }) diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 5ffa098c..2fd6cdcd 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -15,7 +15,7 @@ import superjson from "superjson"; import { trpc } from "./client"; import { getQueryClient } from "./query-client"; import type { AppRouter } from "@/server/trpc/root"; -import { createCollections, type Collections } from "@/lib/collections"; +import { createCollections } from "@/lib/collections"; import { CollectionsProvider } from "@/lib/collections/context"; import { VanillaClientProvider, type VanillaClient } from "./vanilla-client"; @@ -170,9 +170,10 @@ export function TRPCProvider({ children }: TRPCProviderProps) { }) ); - // Initialize TanStack DB collections - const [collections] = useState(() => { - const cols = createCollections(queryClient, { + // Initialize TanStack DB collections and query cache subscription. + // Both are stored together so the cleanup function is accessible for teardown. + const [{ collections, cleanup: collectionsCleanup }] = useState(() => { + const { collections: cols, cleanup } = createCollections(queryClient, { fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), }); @@ -194,9 +195,16 @@ export function TRPCProvider({ children }: TRPCProviderProps) { } } - return cols; + return { collections: cols, cleanup }; }); + // Clean up the query cache subscription when the provider unmounts + useEffect(() => { + return () => { + collectionsCleanup(); + }; + }, [collectionsCleanup]); + return ( From 8fe9ef8123464f496ce3457c44d5613f09c3c989 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:38:08 +0000 Subject: [PATCH 17/31] Remove redundant fetch probe from SSE connection setup (#574) The createConnection function was making a fetch() request to check for 503 status before opening an EventSource to the same URL, causing two HTTP requests per connection attempt. Now EventSource is created directly and error handling switches to polling mode when the connection fails. Also fixes pre-existing lint issues: - performSyncRef updated during render instead of in useEffect - cleanup/stopPolling calling setState synchronously in effect body (replaced isPollingMode state with derivation from connectionStatus) Co-Authored-By: Claude Opus 4.6 --- src/lib/hooks/useRealtimeUpdates.ts | 211 ++++++++-------------------- 1 file changed, 62 insertions(+), 149 deletions(-) diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index fe7fc682..61498e60 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -7,7 +7,6 @@ * - Primary: Server-Sent Events (SSE) via Redis pub/sub * - Fallback: Polling sync endpoint when SSE is unavailable (Redis down) * - Automatic catch-up sync after SSE reconnection - * - Exponential backoff for reconnection attempts * - React Query cache invalidation * - Granular cursor tracking for each entity type (entries, subscriptions, tags, etc.) */ @@ -69,21 +68,6 @@ export interface UseRealtimeUpdatesResult { // Constants // ============================================================================ -/** - * Maximum reconnection delay in milliseconds (30 seconds). - */ -const MAX_RECONNECT_DELAY_MS = 30_000; - -/** - * Initial reconnection delay in milliseconds (1 second). - */ -const INITIAL_RECONNECT_DELAY_MS = 1_000; - -/** - * Backoff multiplier for exponential backoff. - */ -const BACKOFF_MULTIPLIER = 2; - /** * Polling interval when in fallback mode (30 seconds). */ @@ -145,16 +129,13 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda const utils = trpc.useUtils(); const collections = useCollections(); - // Connection status state + // Connection status state - isPolling is derived from connectionStatus === "polling" const [connectionStatus, setConnectionStatus] = useState("disconnected"); - const [isPollingMode, setIsPollingMode] = useState(false); // Refs to persist across renders const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef | null>(null); const pollIntervalRef = useRef | null>(null); const sseRetryTimeoutRef = useRef | null>(null); - const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY_MS); const isManuallyClosedRef = useRef(false); const shouldConnectRef = useRef(false); // Initialize with server-provided cursors (granular tracking per entity type) @@ -174,14 +155,9 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda const isAuthenticated = userQuery.isSuccess && userQuery.data?.user; /** - * Cleans up all connections and intervals. + * Cleans up all connections, intervals, and resets polling state. */ const cleanup = useCallback(() => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; @@ -289,7 +265,11 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return null; } }, [utils, updateCursorForEvent, collections]); - performSyncRef.current = performSync; + + // Keep ref in sync so self-referencing setTimeout always calls the latest version + useEffect(() => { + performSyncRef.current = performSync; + }, [performSync]); /** * Starts polling mode when SSE is unavailable. @@ -299,7 +279,6 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return; // Already polling } - setIsPollingMode(true); setConnectionStatus("polling"); // cursorsRef already initialized with server-provided cursors @@ -321,43 +300,19 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } - setIsPollingMode(false); - }, []); - - /** - * Schedules a reconnection attempt with exponential backoff. - */ - const scheduleReconnect = useCallback((connectFn: () => void) => { - if (isManuallyClosedRef.current || !shouldConnectRef.current) return; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - const delay = reconnectDelayRef.current; - - reconnectTimeoutRef.current = setTimeout(() => { - reconnectDelayRef.current = Math.min( - reconnectDelayRef.current * BACKOFF_MULTIPLIER, - MAX_RECONNECT_DELAY_MS - ); - connectFn(); - }, delay); }, []); /** * Manual reconnection function exposed to consumers. */ const reconnect = useCallback(() => { - reconnectDelayRef.current = INITIAL_RECONNECT_DELAY_MS; isManuallyClosedRef.current = false; shouldConnectRef.current = true; cleanup(); - stopPolling(); setReconnectTrigger((prev) => prev + 1); - }, [cleanup, stopPolling]); + }, [cleanup]); // Effect to manage SSE connection based on authentication useEffect(() => { @@ -366,7 +321,6 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda if (!isAuthenticated) { isManuallyClosedRef.current = true; cleanup(); - stopPolling(); return; } @@ -379,111 +333,72 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda cleanup(); isManuallyClosedRef.current = false; - const createConnection = async () => { + const createConnection = () => { if (!shouldConnectRef.current || isManuallyClosedRef.current) { return; } setConnectionStatus("connecting"); - try { - // First, try a fetch to check if SSE is available - // This handles the 503 case where Redis is down - const response = await fetch("/api/v1/events", { - method: "GET", - credentials: "include", - headers: { - Accept: "text/event-stream", - }, - }); - - // If we get a 503, switch to polling mode - if (response.status === 503) { - console.log("SSE unavailable (503), switching to polling mode"); + // Create EventSource directly - no probe fetch needed. + // If the server returns an error (e.g. 503 when Redis is down), + // EventSource will fire onerror with readyState CLOSED. + const eventSource = new EventSource("/api/v1/events", { + withCredentials: true, + }); + + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setConnectionStatus("connected"); + + // Stop polling if we were in polling mode + stopPolling(); + + // Clear SSE retry timeout + if (sseRetryTimeoutRef.current) { + clearTimeout(sseRetryTimeoutRef.current); + sseRetryTimeoutRef.current = null; + } + + // Perform a catch-up sync to get any changes we might have missed + // The cursorsRef is already initialized, so performSync will work correctly + performSync(); + }; + + // Handle named events + eventSource.addEventListener("connected", handleEvent); // Initial cursor from server + eventSource.addEventListener("new_entry", handleEvent); + eventSource.addEventListener("entry_updated", handleEvent); + eventSource.addEventListener("entry_state_changed", handleEvent); + eventSource.addEventListener("subscription_created", handleEvent); + eventSource.addEventListener("subscription_deleted", handleEvent); + eventSource.addEventListener("tag_created", handleEvent); + eventSource.addEventListener("tag_updated", handleEvent); + eventSource.addEventListener("tag_deleted", handleEvent); + eventSource.addEventListener("import_progress", handleEvent); + eventSource.addEventListener("import_completed", handleEvent); + + eventSource.onerror = () => { + if (eventSourceRef.current?.readyState === EventSource.CLOSED) { + // Connection failed or server closed it (e.g. 503, network error). + // Switch to polling as fallback and schedule an SSE retry. + setConnectionStatus("error"); + cleanup(); startPolling(); - // Schedule periodic SSE retry + // Periodically retry SSE to recover from transient issues sseRetryTimeoutRef.current = setTimeout(() => { if (shouldConnectRef.current && !isManuallyClosedRef.current) { stopPolling(); createConnection(); } }, SSE_RETRY_INTERVAL_MS); - - return; - } - - // If not OK, treat as error - if (!response.ok) { - throw new Error(`SSE request failed with status ${response.status}`); + } else { + // readyState is CONNECTING - EventSource is auto-reconnecting + setConnectionStatus("connecting"); } - - // Close the fetch response since we'll use EventSource - // The fetch was just to check availability - response.body?.cancel(); - - // Create EventSource connection - const eventSource = new EventSource("/api/v1/events", { - withCredentials: true, - }); - - eventSourceRef.current = eventSource; - - eventSource.onopen = () => { - setConnectionStatus("connected"); - reconnectDelayRef.current = INITIAL_RECONNECT_DELAY_MS; - - // Stop polling if we were in polling mode - stopPolling(); - - // Clear SSE retry timeout - if (sseRetryTimeoutRef.current) { - clearTimeout(sseRetryTimeoutRef.current); - sseRetryTimeoutRef.current = null; - } - - // Perform a catch-up sync to get any changes we might have missed - // The cursorsRef is already initialized, so performSync will work correctly - performSync(); - }; - - // Handle named events - eventSource.addEventListener("connected", handleEvent); // Initial cursor from server - eventSource.addEventListener("new_entry", handleEvent); - eventSource.addEventListener("entry_updated", handleEvent); - eventSource.addEventListener("entry_state_changed", handleEvent); - eventSource.addEventListener("subscription_created", handleEvent); - eventSource.addEventListener("subscription_deleted", handleEvent); - eventSource.addEventListener("tag_created", handleEvent); - eventSource.addEventListener("tag_updated", handleEvent); - eventSource.addEventListener("tag_deleted", handleEvent); - eventSource.addEventListener("import_progress", handleEvent); - eventSource.addEventListener("import_completed", handleEvent); - - eventSource.onerror = () => { - if (eventSourceRef.current?.readyState === EventSource.CLOSED) { - setConnectionStatus("error"); - cleanup(); - scheduleReconnect(createConnection); - } else { - setConnectionStatus("connecting"); - } - }; - } catch (error) { - console.error("Failed to create SSE connection:", error); - setConnectionStatus("error"); - - // On connection failure, try polling as fallback - startPolling(); - - // Schedule SSE retry - sseRetryTimeoutRef.current = setTimeout(() => { - if (shouldConnectRef.current && !isManuallyClosedRef.current) { - stopPolling(); - createConnection(); - } - }, SSE_RETRY_INTERVAL_MS); - } + }; }; createConnection(); @@ -491,14 +406,12 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return () => { isManuallyClosedRef.current = true; cleanup(); - stopPolling(); }; }, [ isAuthenticated, reconnectTrigger, cleanup, handleEvent, - scheduleReconnect, startPolling, stopPolling, performSync, @@ -513,7 +426,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda eventSourceRef.current?.readyState !== EventSource.OPEN ) { // If in polling mode, do an immediate sync - if (isPollingMode) { + if (connectionStatus === "polling") { performSync(); } else { reconnect(); @@ -526,7 +439,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [isAuthenticated, isPollingMode, performSync, reconnect]); + }, [isAuthenticated, connectionStatus, performSync, reconnect]); // Derive the effective status const effectiveStatus: ConnectionStatus = isAuthenticated ? connectionStatus : "disconnected"; @@ -534,7 +447,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return { status: effectiveStatus, isConnected: effectiveStatus === "connected" || effectiveStatus === "polling", - isPolling: isPollingMode, + isPolling: effectiveStatus === "polling", reconnect, }; } From 3b66b2b6e992ca7981b8f424fe69427c1639e656 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 06:53:58 +0000 Subject: [PATCH 18/31] Update entry_state_changed SSE events to include count delta data (#572) The entry_state_changed SSE event now includes subscriptionId, previousRead, and previousStarred fields. This allows other tabs/devices receiving the event to compute and apply count deltas for subscription, tag, uncategorized, and global unread/starred counts. Server changes: - Add subscriptionId, previousRead, previousStarred to EntryStateChangedEvent - Pass these fields from markRead and setStarred mutations - Update SSE event parser to validate and forward new fields Client changes: - Add optional fields to entryStateChangedEventSchema (optional so sync polling events that lack previous state still validate) - Compute and apply count deltas in the event handler when previous state is available Co-Authored-By: Claude Opus 4.6 --- src/lib/cache/event-handlers.ts | 50 +++++++++++++++++++++++++++++- src/lib/events/schemas.ts | 6 ++++ src/server/redis/pubsub.ts | 24 ++++++++++++-- src/server/trpc/routers/entries.ts | 25 +++++++++++---- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index b5034761..3401793c 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -19,7 +19,12 @@ import { updateEntryReadInCollection, updateEntryStarredInCollection, updateEntryMetadataInCollection, + adjustSubscriptionUnreadInCollection, + adjustTagUnreadInCollection, + adjustUncategorizedUnreadInCollection, + adjustEntriesCountInCollection, } from "@/lib/collections/writes"; +import { calculateTagDeltasFromSubscriptions } from "./count-cache"; // Re-export SyncEvent type from the shared schema export type { SyncEvent } from "@/lib/events/schemas"; @@ -80,7 +85,7 @@ export function handleSyncEvent( break; } - case "entry_state_changed": + case "entry_state_changed": { // Update entries.get cache (detail view) utils.entries.get.setData({ id: event.entryId }, (oldData) => { if (!oldData) return oldData; @@ -92,7 +97,50 @@ export function handleSyncEvent( // Update entries collection (list view, via reactive useLiveQuery) updateEntryReadInCollection(collections ?? null, [event.entryId], event.read); updateEntryStarredInCollection(collections ?? null, event.entryId, event.starred); + + // Update counts based on state deltas (only when previous state is available, + // i.e. from SSE events; sync polling events don't include previous state) + const readChanged = event.previousRead !== undefined && event.read !== event.previousRead; + const starredChanged = + event.previousStarred !== undefined && event.starred !== event.previousStarred; + + if (readChanged) { + // delta is -1 when marking read (fewer unread), +1 when marking unread + const unreadDelta = event.read ? -1 : 1; + + // Update global "all" unread count + adjustEntriesCountInCollection(collections ?? null, "all", 0, unreadDelta); + + // Update subscription unread count + if (event.subscriptionId) { + const subscriptionDeltas = new Map(); + subscriptionDeltas.set(event.subscriptionId, unreadDelta); + adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); + + // Update tag/uncategorized unread counts + const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( + subscriptionDeltas, + collections ?? null + ); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); + } + } + + if (starredChanged) { + // Adjust starred total and unread counts + const starredTotalDelta = event.starred ? 1 : -1; + // If the entry is unread, it also affects the starred unread count + const starredUnreadDelta = !event.read ? starredTotalDelta : 0; + adjustEntriesCountInCollection( + collections ?? null, + "starred", + starredTotalDelta, + starredUnreadDelta + ); + } break; + } case "subscription_created": { const { subscription, feed } = event; diff --git a/src/lib/events/schemas.ts b/src/lib/events/schemas.ts index ec5ce1d6..bd5ea85a 100644 --- a/src/lib/events/schemas.ts +++ b/src/lib/events/schemas.ts @@ -92,6 +92,12 @@ const entryStateChangedEventSchema = z entryId: z.string(), read: z.boolean(), starred: z.boolean(), + /** Subscription ID for count delta computation (null for saved/orphaned entries) */ + subscriptionId: z.string().nullable().optional(), + /** Previous read state before this change (absent in sync polling events) */ + previousRead: z.boolean().optional(), + /** Previous starred state before this change (absent in sync polling events) */ + previousStarred: z.boolean().optional(), timestamp: z.string().default(defaultTimestamp), updatedAt: z.string(), }) diff --git a/src/server/redis/pubsub.ts b/src/server/redis/pubsub.ts index 42f1c1da..67c4a9a9 100644 --- a/src/server/redis/pubsub.ts +++ b/src/server/redis/pubsub.ts @@ -170,6 +170,12 @@ export interface EntryStateChangedEvent { entryId: string; read: boolean; starred: boolean; + /** Subscription ID for count delta computation (null for saved/orphaned entries) */ + subscriptionId: string | null; + /** Previous read state before this change, for computing count deltas */ + previousRead: boolean; + /** Previous starred state before this change, for computing count deltas */ + previousStarred: boolean; timestamp: string; /** Database updated_at for cursor tracking (entries cursor) */ updatedAt: string; @@ -545,6 +551,9 @@ export async function publishImportCompleted( * @param read - Current read state * @param starred - Current starred state * @param updatedAt - The database updated_at timestamp for cursor tracking + * @param subscriptionId - The subscription ID (null for saved/orphaned entries) + * @param previousRead - The read state before this change + * @param previousStarred - The starred state before this change * @returns The number of subscribers that received the message (0 if Redis unavailable) */ export async function publishEntryStateChanged( @@ -552,7 +561,10 @@ export async function publishEntryStateChanged( entryId: string, read: boolean, starred: boolean, - updatedAt: Date + updatedAt: Date, + subscriptionId: string | null, + previousRead: boolean, + previousStarred: boolean ): Promise { const client = getPublisherClient(); if (!client) { @@ -564,6 +576,9 @@ export async function publishEntryStateChanged( entryId, read, starred, + subscriptionId, + previousRead, + previousStarred, timestamp: new Date().toISOString(), updatedAt: updatedAt.toISOString(), }; @@ -903,7 +918,9 @@ export function parseUserEvent(message: string): UserEvent | null { typeof event.read === "boolean" && typeof event.starred === "boolean" && typeof event.timestamp === "string" && - typeof event.updatedAt === "string" + typeof event.updatedAt === "string" && + typeof event.previousRead === "boolean" && + typeof event.previousStarred === "boolean" ) { return { type: "entry_state_changed", @@ -911,6 +928,9 @@ export function parseUserEvent(message: string): UserEvent | null { entryId: event.entryId, read: event.read, starred: event.starred, + subscriptionId: typeof event.subscriptionId === "string" ? event.subscriptionId : null, + previousRead: event.previousRead, + previousStarred: event.previousStarred, timestamp: event.timestamp, updatedAt: event.updatedAt, }; diff --git a/src/server/trpc/routers/entries.ts b/src/server/trpc/routers/entries.ts index b6bb6f2c..dc729cb9 100644 --- a/src/server/trpc/routers/entries.ts +++ b/src/server/trpc/routers/entries.ts @@ -347,6 +347,7 @@ async function updateEntryStarred( changedAt: Date = new Date() ): Promise<{ id: string; + subscriptionId: string | null; read: boolean; starred: boolean; updatedAt: Date; @@ -381,6 +382,7 @@ async function updateEntryStarred( const result = await ctx.db .select({ id: visibleEntries.id, + subscriptionId: visibleEntries.subscriptionId, read: visibleEntries.read, starred: visibleEntries.starred, updatedAt: visibleEntries.updatedAt, @@ -400,6 +402,7 @@ async function updateEntryStarred( const row = result[0]; return { id: row.id, + subscriptionId: row.subscriptionId, read: row.read, starred: row.starred, updatedAt: row.updatedAt, @@ -673,7 +676,10 @@ export const entriesRouter = createTRPCRouter({ entry.id, entry.read, entry.starred, - entry.updatedAt + entry.updatedAt, + entry.subscriptionId, + !input.read, // previousRead: opposite of target state + entry.starred // previousStarred: unchanged by markRead ).catch(() => { // Ignore publish errors - SSE is best-effort }); @@ -882,11 +888,18 @@ export const entriesRouter = createTRPCRouter({ // Publish entry state change event for multi-tab/device sync // Fire and forget - don't block the response - publishEntryStateChanged(userId, entry.id, entry.read, entry.starred, entry.updatedAt).catch( - () => { - // Ignore publish errors - SSE is best-effort - } - ); + publishEntryStateChanged( + userId, + entry.id, + entry.read, + entry.starred, + entry.updatedAt, + entry.subscriptionId, + entry.read, // previousRead: unchanged by setStarred + !input.starred // previousStarred: opposite of target state + ).catch(() => { + // Ignore publish errors - SSE is best-effort + }); return { entry, counts }; }), From 3e0626be84e6b010dd49f7ab76af1ba5ff5dea64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 07:00:45 +0000 Subject: [PATCH 19/31] Add totalCount to subscription model for correct entry count deltas (#576) handleSubscriptionCreated/Deleted was using unreadCount for both the totalDelta and unreadDelta arguments to adjustEntriesCountInCollection. When a subscription is deleted after some entries have been read, totalCount != unreadCount, so the total entry count would be decremented by the wrong amount. This adds totalCount as a new field throughout the subscription data model (service, tRPC schema, SSE events, Zod schemas, sync endpoint) and uses it for the totalDelta parameter in both handlers. Co-Authored-By: Claude Opus 4.6 --- src/lib/cache/event-handlers.ts | 1 + src/lib/cache/operations.ts | 5 ++-- src/lib/events/schemas.ts | 1 + src/server/email/process-inbound.ts | 1 + src/server/jobs/handlers.ts | 1 + src/server/redis/pubsub.ts | 3 +++ src/server/services/subscriptions.ts | 22 ++++++++++++++++- src/server/trpc/routers/subscriptions.ts | 11 +++++++++ src/server/trpc/routers/sync.ts | 26 ++++++++++++++++++++ tests/unit/frontend/cache/operations.test.ts | 1 + 10 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 3401793c..c6787766 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -156,6 +156,7 @@ export function handleSyncEvent( siteUrl: feed.siteUrl, subscribedAt: new Date(subscription.subscribedAt), unreadCount: subscription.unreadCount, + totalCount: subscription.totalCount, tags: subscription.tags, fetchFullContent: false, }, diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index 15e528c5..d1ff346d 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -38,6 +38,7 @@ export interface SubscriptionData { siteUrl: string | null; subscribedAt: Date; unreadCount: number; + totalCount: number; tags: Array<{ id: string; name: string; color: string | null }>; fetchFullContent: boolean; } @@ -74,7 +75,7 @@ export function handleSubscriptionCreated( adjustEntriesCountInCollection( collections ?? null, "all", - subscription.unreadCount, + subscription.totalCount, subscription.unreadCount ); } @@ -114,7 +115,7 @@ export function handleSubscriptionDeleted( adjustEntriesCountInCollection( collections ?? null, "all", - -subscription.unreadCount, + -subscription.totalCount, -subscription.unreadCount ); } diff --git a/src/lib/events/schemas.ts b/src/lib/events/schemas.ts index bd5ea85a..8e7ca7df 100644 --- a/src/lib/events/schemas.ts +++ b/src/lib/events/schemas.ts @@ -39,6 +39,7 @@ const subscriptionDataSchema = z.object({ customTitle: z.string().nullable(), subscribedAt: z.string(), unreadCount: z.number(), + totalCount: z.number(), tags: z.array(tagSchema), }); diff --git a/src/server/email/process-inbound.ts b/src/server/email/process-inbound.ts index 2f020629..7e1fe705 100644 --- a/src/server/email/process-inbound.ts +++ b/src/server/email/process-inbound.ts @@ -340,6 +340,7 @@ export async function processInboundEmail(email: InboundEmail): Promise; } @@ -800,6 +801,7 @@ export function parseUserEvent(message: string): UserEvent | null { (sub.customTitle !== null && typeof sub.customTitle !== "string") || typeof sub.subscribedAt !== "string" || typeof sub.unreadCount !== "number" || + typeof sub.totalCount !== "number" || !Array.isArray(sub.tags) ) { return null; @@ -830,6 +832,7 @@ export function parseUserEvent(message: string): UserEvent | null { customTitle: sub.customTitle as string | null, subscribedAt: sub.subscribedAt, unreadCount: sub.unreadCount, + totalCount: sub.totalCount as number, tags: sub.tags as Array<{ id: string; name: string; color: string | null }>, }, feed: { diff --git a/src/server/services/subscriptions.ts b/src/server/services/subscriptions.ts index 9502962d..b112359e 100644 --- a/src/server/services/subscriptions.ts +++ b/src/server/services/subscriptions.ts @@ -29,6 +29,7 @@ export interface Subscription { siteUrl: string | null; subscribedAt: Date; unreadCount: number; + totalCount: number; tags: Tag[]; fetchFullContent: boolean; } @@ -57,6 +58,20 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { .groupBy(entries.feedId) .as("unread_counts"); + // Subquery to get total entry counts per feed (all user_entries, not just unread) + const totalCountsSubquery = db + .select({ + feedId: entries.feedId, + totalCount: sql`count(*)::int`.as("total_count"), + }) + .from(entries) + .innerJoin( + userEntries, + and(eq(userEntries.entryId, entries.id), eq(userEntries.userId, userId)) + ) + .groupBy(entries.feedId) + .as("total_counts"); + return db .select({ // From user_feeds view - subscription fields @@ -73,6 +88,8 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { siteUrl: userFeeds.siteUrl, // Unread count from subquery unreadCount: sql`COALESCE(${unreadCountsSubquery.unreadCount}, 0)`, + // Total entry count from subquery + totalCount: sql`COALESCE(${totalCountsSubquery.totalCount}, 0)`, // Tags aggregated as JSON array tags: sql>` COALESCE( @@ -85,6 +102,7 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { }) .from(userFeeds) .leftJoin(unreadCountsSubquery, eq(unreadCountsSubquery.feedId, userFeeds.feedId)) + .leftJoin(totalCountsSubquery, eq(totalCountsSubquery.feedId, userFeeds.feedId)) .leftJoin(subscriptionTags, eq(subscriptionTags.subscriptionId, userFeeds.id)) .leftJoin(tags, eq(tags.id, subscriptionTags.tagId)) .groupBy( @@ -98,7 +116,8 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { userFeeds.originalTitle, userFeeds.description, userFeeds.siteUrl, - unreadCountsSubquery.unreadCount + unreadCountsSubquery.unreadCount, + totalCountsSubquery.totalCount ); } @@ -121,6 +140,7 @@ export function formatSubscriptionRow(row: SubscriptionQueryRow): Subscription { siteUrl: row.siteUrl, subscribedAt: row.subscribedAt, unreadCount: row.unreadCount, + totalCount: row.totalCount, tags: row.tags, fetchFullContent: row.fetchFullContent, }; diff --git a/src/server/trpc/routers/subscriptions.ts b/src/server/trpc/routers/subscriptions.ts index e884b291..3a514c4a 100644 --- a/src/server/trpc/routers/subscriptions.ts +++ b/src/server/trpc/routers/subscriptions.ts @@ -76,6 +76,7 @@ const subscriptionOutputSchema = z.object({ siteUrl: z.string().nullable(), subscribedAt: z.date(), unreadCount: z.number(), + totalCount: z.number(), tags: z.array(tagOutputSchema), fetchFullContent: z.boolean(), // whether to fetch full article content from URL }); @@ -294,6 +295,7 @@ async function subscribeToExistingFeed( customTitle: null, subscribedAt: result.subscribedAt.toISOString(), unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, }; @@ -319,6 +321,7 @@ async function subscribeToExistingFeed( siteUrl: feedRecord.siteUrl, subscribedAt: result.subscribedAt, unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, fetchFullContent: false, // default for new subscriptions }; @@ -475,6 +478,7 @@ async function subscribeToNewOrUnfetchedFeed( customTitle: null, subscribedAt: result.subscribedAt.toISOString(), unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, }; @@ -500,6 +504,7 @@ async function subscribeToNewOrUnfetchedFeed( siteUrl: feedRecord.siteUrl, subscribedAt: result.subscribedAt, unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, fetchFullContent: false, // default for new subscriptions }; @@ -735,6 +740,10 @@ export const subscriptionsRouter = createTRPCRouter({ unreadCount: sql` COUNT(${entries.id}) FILTER (WHERE ${userEntries.read} = false)::int `, + // Total entry count from user_entries + totalCount: sql` + COUNT(${entries.id}) FILTER (WHERE ${userEntries.userId} IS NOT NULL)::int + `, }) .from(feeds) .leftJoin(subscriptionTags, eq(subscriptionTags.subscriptionId, subscription.id)) @@ -755,6 +764,7 @@ export const subscriptionsRouter = createTRPCRouter({ const subscriptionTagsList = result.tags; const unreadCount = result.unreadCount; + const totalCount = result.totalCount; // Return flat format return { @@ -767,6 +777,7 @@ export const subscriptionsRouter = createTRPCRouter({ siteUrl: result.siteUrl, subscribedAt: subscription.subscribedAt, unreadCount, + totalCount, tags: subscriptionTagsList, fetchFullContent: subscription.fetchFullContent, }; diff --git a/src/server/trpc/routers/sync.ts b/src/server/trpc/routers/sync.ts index 4a7195f1..aff44661 100644 --- a/src/server/trpc/routers/sync.ts +++ b/src/server/trpc/routers/sync.ts @@ -116,6 +116,7 @@ const subscriptionCreatedDataSchema = z.object({ customTitle: z.string().nullable(), subscribedAt: z.string(), unreadCount: z.number(), + totalCount: z.number(), tags: z.array( z.object({ id: z.string(), @@ -971,6 +972,30 @@ export const syncRouter = createTRPCRouter({ } } + // Batch-fetch total entry counts for all active subscriptions + const totalBySubscription = new Map(); + if (activeSubscriptions.length > 0) { + const totalResults = await Promise.all( + activeSubscriptions.map(async ({ subscription }) => { + const result = await ctx.db + .select({ count: sql`count(*)::int` }) + .from(userEntries) + .innerJoin(entries, eq(entries.id, userEntries.entryId)) + .where( + and( + eq(userEntries.userId, userId), + sql`${entries.feedId} = ANY(${subscription.feedIds})` + ) + ); + return { subscriptionId: subscription.id, count: result[0]?.count ?? 0 }; + }) + ); + + for (const { subscriptionId, count } of totalResults) { + totalBySubscription.set(subscriptionId, count); + } + } + for (const { subscription, feed } of subscriptionResults) { if (subscription.unsubscribedAt === null) { allEvents.push({ @@ -985,6 +1010,7 @@ export const syncRouter = createTRPCRouter({ customTitle: subscription.customTitle, subscribedAt: subscription.subscribedAt.toISOString(), unreadCount: unreadBySubscription.get(subscription.id) ?? 0, + totalCount: totalBySubscription.get(subscription.id) ?? 0, tags: tagsBySubscription.get(subscription.id) ?? [], }, feed: { diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index dfb55345..e69400de 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -32,6 +32,7 @@ function createSubscription(overrides: Partial = {}): Subscrip siteUrl: "https://example.com", subscribedAt: new Date("2024-01-01"), unreadCount: 0, + totalCount: 0, tags: [], fetchFullContent: false, ...overrides, From 161d661de5d08e08f5633b6284b9dfefc3110ecf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 07:09:23 +0000 Subject: [PATCH 20/31] Fix entry lists not updating on navigation or new entries (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view collection (TanStack DB query-backed collection) used staleTime: Infinity and was never invalidated when new entries arrived via SSE or when the user navigated. Additionally, all call sites used utils.entries.list.invalidate() which only invalidated the React Query cache — not the view collection's separate query cache. Changes: - Add invalidateActiveView() method to Collections interface - Set it in useViewEntriesCollection when the backing query is created - Call it on new_entry SSE events so new articles appear in the list - Call it on subscription delete so removed entries disappear - Replace utils.entries.list.invalidate() with invalidateActiveView() in SuspendingEntryList, Sidebar, useEntryMutations, FileUploadButton - Update FRONTEND_STATE.md to reflect the new invalidation pattern Co-Authored-By: Claude Opus 4.6 --- src/FRONTEND_STATE.md | 4 ++-- src/components/entries/SuspendingEntryList.tsx | 6 ++---- src/components/layout/Sidebar.tsx | 6 +++--- src/components/saved/FileUploadButton.tsx | 1 + src/lib/cache/event-handlers.ts | 2 ++ src/lib/cache/operations.ts | 3 ++- src/lib/collections/index.ts | 7 +++++++ src/lib/hooks/useEntryMutations.ts | 1 + src/lib/hooks/useKeyboardShortcuts.ts | 2 +- src/lib/hooks/useViewEntriesCollection.ts | 10 ++++++++-- 10 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/FRONTEND_STATE.md b/src/FRONTEND_STATE.md index c96bda42..de46005c 100644 --- a/src/FRONTEND_STATE.md +++ b/src/FRONTEND_STATE.md @@ -152,11 +152,11 @@ Centralized helpers in `src/lib/cache/` ensure consistent updates across the cod The `useRealtimeUpdates` hook manages SSE connections and updates caches. -**Key principle:** Direct cache updates where possible, invalidation only when necessary. `entries.list` is NOT invalidated for new entries - users see new entries when they navigate (sidebar counts update immediately). +**Key principle:** Direct cache updates where possible, invalidation only when necessary. The active view collection is invalidated on new entries, subscription deletes, and navigation so the entry list stays current. | SSE Event | Cache Updates | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `new_entry` | Direct: `subscriptions.list` unreadCount, `tags.list` unreadCount, `entries.count`. Does NOT invalidate `entries.list`. | +| `new_entry` | Direct: `subscriptions.list` unreadCount, `tags.list` unreadCount, `entries.count`. Invalidates active view collection so new entries appear. | | `entry_updated` | Direct: `entries.get`, `entries.list` (metadata: title, author, summary, url, publishedAt). No invalidation - avoids race condition when viewing. | | `subscription_created` | Direct: add to `subscriptions.list`, update `tags.list` feedCount/unreadCount, update `entries.count`. | | `subscription_deleted` | Direct: remove from `subscriptions.list`, update `tags.list`, update `entries.count`. Invalidate: `entries.list`. | diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index c135ba3e..2f40d579 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -19,7 +19,6 @@ import { useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { useLiveInfiniteQuery } from "@tanstack/react-db"; import { eq } from "@tanstack/db"; -import { trpc } from "@/lib/trpc/client"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; import { useKeyboardShortcutsContext } from "@/components/keyboard/KeyboardShortcutsProvider"; @@ -44,7 +43,6 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) const { openEntryId, setOpenEntryId } = useEntryUrlState(); const { showUnreadOnly, sortOrder, toggleShowUnreadOnly } = useUrlViewPreferences(); const { enabled: keyboardShortcutsEnabled } = useKeyboardShortcutsContext(); - const utils = trpc.useUtils(); const scrollContainerRef = useScrollContainer(); const collections = useCollections(); @@ -234,7 +232,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) enabled: keyboardShortcutsEnabled, onToggleRead: handleToggleRead, onToggleStar: toggleStar, - onRefresh: () => utils.entries.list.invalidate(), + onRefresh: () => collections.invalidateActiveView(), onToggleUnreadOnly: toggleShowUnreadOnly, onNavigateNext: goToNextEntry, onNavigatePrevious: goToPreviousEntry, @@ -253,7 +251,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) isFetchingNextPage, hasNextPage, fetchNextPage, - refetch: () => utils.entries.list.invalidate(), + refetch: () => collections.invalidateActiveView(), }; return ( diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index af30c6c9..81c523e6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -80,10 +80,10 @@ export function Sidebar({ onClose }: SidebarProps) { }); const handleNavigate = () => { - // Mark entry list queries as stale and refetch active ones. + // Invalidate the view collection so the entry list refetches. // This ensures clicking the current page's link refreshes the list, - // while cross-page navigation also works (new query fetches on mount). - utils.entries.list.invalidate(); + // while cross-page navigation also works (new collection creates on mount). + collections.invalidateActiveView(); onClose?.(); }; diff --git a/src/components/saved/FileUploadButton.tsx b/src/components/saved/FileUploadButton.tsx index d0547e03..dd2b6b53 100644 --- a/src/components/saved/FileUploadButton.tsx +++ b/src/components/saved/FileUploadButton.tsx @@ -75,6 +75,7 @@ export function FileUploadButton({ className = "", onSuccess }: FileUploadButton toast.success("File uploaded successfully"); // Invalidate entries list and update counts in collection utils.entries.list.invalidate({ type: "saved" }); + collections.invalidateActiveView(); adjustEntriesCountInCollection(collections, "saved", 1, 1); adjustEntriesCountInCollection(collections, "all", 1, 1); onSuccess?.(); diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index c6787766..785aa76b 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -55,6 +55,8 @@ export function handleSyncEvent( if (event.feedType) { handleNewEntry(utils, event.subscriptionId, event.feedType, collections); } + // Invalidate view collection so the new entry appears in the list + collections?.invalidateActiveView(); break; case "entry_updated": { diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index d1ff346d..8c5da9a6 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -123,8 +123,9 @@ export function handleSubscriptionDeleted( // Remove from collection removeSubscriptionFromCollection(collections ?? null, subscriptionId); - // Invalidate entries.list - entries from this subscription should be filtered out + // Invalidate entries list - entries from this subscription should be filtered out utils.entries.list.invalidate(); + collections?.invalidateActiveView(); } /** diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index 292acf92..d315cd3a 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -60,6 +60,12 @@ export interface Collections { counts: CountsCollection; /** The active on-demand view collection, set by useViewEntriesCollection */ activeViewCollection: ViewEntriesCollection | null; + /** + * Invalidate the active view collection's query cache to trigger a refetch. + * Set by useViewEntriesCollection; no-op when no view collection is active. + * Call this instead of utils.entries.list.invalidate() to refresh the entry list. + */ + invalidateActiveView: () => void; } /** @@ -144,6 +150,7 @@ export function createCollections( entries: createEntriesCollection(), counts, activeViewCollection: null, + invalidateActiveView: () => {}, }; // Seed uncategorized counts from SSR-prefetched tags.list data diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index e90ad362..50686bc6 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -370,6 +370,7 @@ export function useEntryMutations(): UseEntryMutationsResult { const markAllReadMutation = trpc.entries.markAllRead.useMutation({ onSuccess: () => { utils.entries.list.invalidate(); + collections.invalidateActiveView(); utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); collections.tags.utils.refetch(); diff --git a/src/lib/hooks/useKeyboardShortcuts.ts b/src/lib/hooks/useKeyboardShortcuts.ts index 70cf7825..d8bef051 100644 --- a/src/lib/hooks/useKeyboardShortcuts.ts +++ b/src/lib/hooks/useKeyboardShortcuts.ts @@ -168,7 +168,7 @@ export interface UseKeyboardShortcutsResult { * isEntryOpen: !!openEntryId, * onToggleRead: (id, read) => markReadMutation.mutate({ ids: [id], read: !read }), * onToggleStar: (id, starred) => starred ? unstarMutation.mutate({ id }) : starMutation.mutate({ id }), - * onRefresh: () => utils.entries.list.invalidate(), + * onRefresh: () => collections.invalidateActiveView(), * }); * * return ( diff --git a/src/lib/hooks/useViewEntriesCollection.ts b/src/lib/hooks/useViewEntriesCollection.ts index 34d271fc..0b35969e 100644 --- a/src/lib/hooks/useViewEntriesCollection.ts +++ b/src/lib/hooks/useViewEntriesCollection.ts @@ -53,16 +53,22 @@ export function useViewEntriesCollection(filters: EntriesViewFilters) { [filterKey, queryClient, vanillaClient] ); - // Register as active view collection for mutations/SSE writes + // Register as active view collection for mutations/SSE writes, + // and set invalidateActiveView to invalidate this collection's backing queries useEffect(() => { collections.activeViewCollection = collection; + const queryKey = ["entries-view", filterKey]; + collections.invalidateActiveView = () => { + queryClient.invalidateQueries({ queryKey }); + }; return () => { // Only clear if we're still the active one (avoid race with new mount) if (collections.activeViewCollection === collection) { collections.activeViewCollection = null; + collections.invalidateActiveView = () => {}; } }; - }, [collections, collection]); + }, [collections, collection, queryClient, filterKey]); return { collection, filterKey }; } From 5300f68024d4c9f58ad01b18e4209ace4e0acbc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Feb 2026 07:16:07 +0000 Subject: [PATCH 21/31] Fix useRef type error by providing explicit initial value useRef() without an initial argument triggers TS2554 in strict mode. Pass undefined explicitly to satisfy the type checker. Co-Authored-By: Claude Opus 4.6 --- src/lib/hooks/useRealtimeUpdates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index 61498e60..93740354 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -141,7 +141,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // Initialize with server-provided cursors (granular tracking per entity type) const cursorsRef = useRef(initialCursors); // Ref for performSync to allow self-referencing without violating react-hooks/immutability - const performSyncRef = useRef<() => Promise>(); + const performSyncRef = useRef<(() => Promise) | undefined>(undefined); // State to trigger reconnection const [reconnectTrigger, setReconnectTrigger] = useState(0); From 8cbcc6bc4a961eed9384a96ae72d960c2c3f7b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 04:20:40 +0000 Subject: [PATCH 22/31] Fix counts collection seeding race condition (#622) Move entry count seeding from TRPCProvider into createCollections and add a query cache subscription that watches for entries.count query updates. This mirrors the existing pattern for uncategorized counts (via tags.list subscription) and ensures counts are synced even when SSR-prefetched data hasn't arrived in the cache at initialization time. Co-Authored-By: Claude Opus 4.6 --- src/lib/collections/index.ts | 112 +++++++++++++++++++++++++++++++---- src/lib/trpc/provider.tsx | 30 ++-------- 2 files changed, 105 insertions(+), 37 deletions(-) diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index d315cd3a..30968b53 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -33,6 +33,12 @@ import { import { createCountsCollection, type CountsCollection } from "./counts"; import type { CountRecord, TagItem, UncategorizedCounts } from "./types"; +/** + * tRPC query key prefix for entries.count queries. + * Full key format: [["entries", "count"], { input: {...}, type: "query" }] + */ +const ENTRIES_COUNT_KEY_PATH = ["entries", "count"] as const; + export type { Subscription, EntryListItem, @@ -105,6 +111,58 @@ function isTagsListKey(queryKey: readonly unknown[]): boolean { ); } +/** + * Check if a query key matches an entries.count key. + */ +function isEntriesCountKey(queryKey: readonly unknown[]): boolean { + if (queryKey.length < 2) return false; + const path = queryKey[0]; + const meta = queryKey[1] as { type?: string } | undefined; + return ( + Array.isArray(path) && + path.length === 2 && + path[0] === ENTRIES_COUNT_KEY_PATH[0] && + path[1] === ENTRIES_COUNT_KEY_PATH[1] && + meta?.type === "query" + ); +} + +/** + * Determine the count key ("all", "starred", or "saved") from entries.count input. + * Returns null if the input doesn't match a known count category. + */ +function getCountKeyFromInput(input: Record | undefined): string | null { + if (!input || Object.keys(input).length === 0) { + return "all"; + } + if (input.starredOnly === true) { + return "starred"; + } + if (input.type === "saved") { + return "saved"; + } + return null; +} + +/** + * Write entry counts from an entries.count response to the counts collection. + */ +function syncEntryCount( + counts: CountsCollection, + countKey: string, + data: { total: number; unread: number } +): void { + const existing = counts.get(countKey); + if (existing) { + counts.update(countKey, (draft: CountRecord) => { + draft.total = data.total; + draft.unread = data.unread; + }); + } else { + counts.insert({ id: countKey, total: data.total, unread: data.unread }); + } +} + /** * Write uncategorized counts from a tags.list response to the counts collection. */ @@ -130,9 +188,12 @@ function syncUncategorizedCounts(counts: CountsCollection, data: TagsListRespons * Called once in the TRPCProvider when the QueryClient is available. * The fetcher functions bridge tRPC with the collection queryFn interface. * - * Sets up a query cache subscription to sync uncategorized counts from - * tags.list responses into the counts collection, keeping the tags - * collection's `select` function pure. + * Sets up a query cache subscription to sync counts from server responses: + * - Uncategorized counts from tags.list responses + * - Entry counts (all/starred/saved) from entries.count responses + * + * This subscription handles both eager seeding (from SSR-prefetched data) + * and async updates (when queries resolve after initialization). * * @param queryClient - The shared QueryClient instance * @param fetchers - Functions to fetch data from the tRPC API @@ -161,17 +222,42 @@ export function createCollections( syncUncategorizedCounts(counts, prefetchedTagsData); } - // Subscribe to query cache updates to sync uncategorized counts - // whenever tags.list data changes (fetches, refetches, SSE invalidations) + // Seed entry counts from SSR-prefetched entries.count queries + const countQueries = queryClient.getQueriesData<{ total: number; unread: number }>({ + queryKey: [ENTRIES_COUNT_KEY_PATH.slice()], + }); + for (const [queryKey, data] of countQueries) { + if (!data) continue; + const keyMeta = queryKey[1] as { input?: Record } | undefined; + const countKey = getCountKeyFromInput(keyMeta?.input); + if (countKey) { + syncEntryCount(counts, countKey, data); + } + } + + // Subscribe to query cache updates to sync counts whenever data changes const unsubscribe = queryClient.getQueryCache().subscribe((event: QueryCacheNotifyEvent) => { - if ( - event.type === "updated" && - event.action.type === "success" && - isTagsListKey(event.query.queryKey) - ) { - const data = event.query.state.data as TagsListResponse | undefined; - if (data) { - syncUncategorizedCounts(counts, data); + if (event.type === "updated" && event.action.type === "success") { + // Sync uncategorized counts from tags.list + if (isTagsListKey(event.query.queryKey)) { + const data = event.query.state.data as TagsListResponse | undefined; + if (data) { + syncUncategorizedCounts(counts, data); + } + } + + // Sync entry counts from entries.count + if (isEntriesCountKey(event.query.queryKey)) { + const data = event.query.state.data as { total: number; unread: number } | undefined; + if (data) { + const keyMeta = event.query.queryKey[1] as + | { input?: Record } + | undefined; + const countKey = getCountKeyFromInput(keyMeta?.input); + if (countKey) { + syncEntryCount(counts, countKey, data); + } + } } } }); diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 2fd6cdcd..e5a69940 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -172,31 +172,13 @@ export function TRPCProvider({ children }: TRPCProviderProps) { // Initialize TanStack DB collections and query cache subscription. // Both are stored together so the cleanup function is accessible for teardown. - const [{ collections, cleanup: collectionsCleanup }] = useState(() => { - const { collections: cols, cleanup } = createCollections(queryClient, { + // Entry counts and uncategorized counts are seeded from prefetched data and + // kept in sync via query cache subscriptions inside createCollections. + const [{ collections, cleanup: collectionsCleanup }] = useState(() => + createCollections(queryClient, { fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), - }); - - // Seed entry counts from SSR-prefetched React Query cache. - // Query key format: [["entries", "count"], { input: {...}, type: "query" }] - const countQueries = queryClient.getQueriesData<{ total: number; unread: number }>({ - queryKey: [["entries", "count"]], - }); - for (const [queryKey, data] of countQueries) { - if (!data) continue; - const keyMeta = queryKey[1] as { input?: Record } | undefined; - const input = keyMeta?.input; - if (!input || Object.keys(input).length === 0) { - cols.counts.insert({ id: "all", total: data.total, unread: data.unread }); - } else if (input.starredOnly === true) { - cols.counts.insert({ id: "starred", total: data.total, unread: data.unread }); - } else if (input.type === "saved") { - cols.counts.insert({ id: "saved", total: data.total, unread: data.unread }); - } - } - - return { collections: cols, cleanup }; - }); + }) + ); // Clean up the query cache subscription when the provider unmounts useEffect(() => { From 1c6c488ffe89b321eaf7df1f6b73f003d1e68452 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 04:23:28 +0000 Subject: [PATCH 23/31] Fix SSE count drift and missing feedType handling (#623) - Handle missing feedType in new_entry SSE events by defaulting to "web" so count updates aren't skipped entirely when the server omits this field - Change tag subscriptions staleTime from Infinity to 60s so expanding a tag section after SSE-driven count drift will trigger a refetch that corrects the counts - Document the acceptable tradeoff where tag/subscription count updates are skipped when subscription data isn't loaded (Goal 4) Co-Authored-By: Claude Opus 4.6 --- src/lib/cache/event-handlers.ts | 23 +++++++++++++++++------ src/lib/cache/operations.ts | 7 ++++--- src/lib/collections/subscriptions.ts | 4 +++- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 1f19e16d..c98c7e91 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -51,10 +51,10 @@ export function handleSyncEvent( ): void { switch (event.type) { case "new_entry": - // Update unread counts in collections - if (event.feedType) { - handleNewEntry(utils, event.subscriptionId, event.feedType, collections); - } + // Update unread counts in collections. + // feedType is optional in the SSE schema (older servers may not send it). + // Default to "web" when missing so count updates still happen. + handleNewEntry(utils, event.subscriptionId, event.feedType ?? "web", collections); // Invalidate view collection so the new entry appears in the list collections?.invalidateActiveView(); break; @@ -113,13 +113,24 @@ export function handleSyncEvent( // Update global "all" unread count adjustEntriesCountInCollection(collections ?? null, "all", 0, unreadDelta); - // Update subscription unread count + // Update per-subscription and tag/uncategorized unread counts. + // + // TRADEOFF (see #623): Subscriptions are loaded on-demand when users + // expand tag sections in the sidebar. If the subscription isn't in + // the collection yet, calculateTagDeltasFromSubscriptions will return + // zero deltas, so tag/uncategorized/per-subscription counts won't be + // adjusted here. This is acceptable per Goal 4 ("Everything works even + // if we don't have per-subscription data"): + // - Global "all" count (above) is always correct. + // - Tag/uncategorized counts may drift temporarily. + // - When tag sections are expanded, the tag subscriptions collection + // refetches from the server (staleTime is finite), which corrects + // the counts. if (event.subscriptionId) { const subscriptionDeltas = new Map(); subscriptionDeltas.set(event.subscriptionId, unreadDelta); adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); - // Update tag/uncategorized unread counts const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( subscriptionDeltas, collections ?? null diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index 8c5da9a6..8a70c224 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -142,15 +142,16 @@ export function handleNewEntry( feedType: "web" | "email" | "saved", collections?: Collections | null ): void { - // Update subscription and tag unread counts + // Update per-subscription and tag/uncategorized unread counts. + // Same tradeoff as entry_state_changed: if the subscription isn't loaded + // in the collection, tag deltas will be zero and counts may drift until + // the tag section is expanded and refetched (see #623). if (subscriptionId) { const subscriptionDeltas = new Map(); subscriptionDeltas.set(subscriptionId, 1); - // Update subscription unread in collection adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); - // Calculate and apply tag deltas using subscription data from collection const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( subscriptionDeltas, collections ?? null diff --git a/src/lib/collections/subscriptions.ts b/src/lib/collections/subscriptions.ts index a066d008..5fe41c4f 100644 --- a/src/lib/collections/subscriptions.ts +++ b/src/lib/collections/subscriptions.ts @@ -102,7 +102,9 @@ export function createTagSubscriptionsCollection( queryKey: ["subscriptions-tag", stableTagFilterKey(filters)], queryClient, getKey: (item: Subscription) => item.id, - staleTime: Infinity, + // Use a finite staleTime so expanding a tag section after SSE events + // have drifted the counts will trigger a refetch that corrects them (#623). + staleTime: 60_000, queryFn: async (ctx) => { const opts = ctx.meta?.loadSubsetOptions as { offset?: number; limit?: number } | undefined; const offset = opts?.offset ?? 0; From d577b1a4f0fa9690a4f854a4c7f8d568196bf482 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 04:27:26 +0000 Subject: [PATCH 24/31] Fix markAllRead not zeroing subscription unread counts in collection (#624) After markAllRead, subscriptions in the TanStack DB global collection still showed their old unread counts because the onSuccess handler only invalidated React Query caches without updating the local collection. Add zeroSubscriptionUnreadForMarkAllRead() to writes.ts that zeroes unread counts based on the mutation's filter (subscriptionId, tagId, or all subscriptions). Call it in the markAllRead onSuccess handler. Co-Authored-By: Claude Opus 4.6 --- src/FRONTEND_STATE.md | 16 +++++------ src/lib/collections/writes.ts | 45 ++++++++++++++++++++++++++++++ src/lib/hooks/useEntryMutations.ts | 9 +++++- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/FRONTEND_STATE.md b/src/FRONTEND_STATE.md index de46005c..07f1eb53 100644 --- a/src/FRONTEND_STATE.md +++ b/src/FRONTEND_STATE.md @@ -96,14 +96,14 @@ Centralized helpers in `src/lib/cache/` ensure consistent updates across the cod ### Entry Mutations -| Mutation | Used In | Cache Updates | -| -------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `entries.markRead` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `subscriptions.list` counts, `tags.list` counts, entry scores. Server returns absolute counts. | -| `entries.markAllRead` | `useEntryMutations` | Invalidate: `entries.list`, `subscriptions.list`, `tags.list`, `entries.count` (bulk operation, direct update not practical) | -| `entries.star` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | -| `entries.unstar` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | -| `entries.setScore` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), entry scores | -| `entries.fetchFullContent` | `EntryContent` | Invalidate: `entries.get({ id })` | +| Mutation | Used In | Cache Updates | +| -------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entries.markRead` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `subscriptions.list` counts, `tags.list` counts, entry scores. Server returns absolute counts. | +| `entries.markAllRead` | `useEntryMutations` | Invalidate: `entries.list`, `subscriptions.list`, `tags.list`, `entries.count`. Direct: subscription unread counts zeroed in collection (by subscriptionId, tagId, or all). | +| `entries.star` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | +| `entries.unstar` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | +| `entries.setScore` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), entry scores | +| `entries.fetchFullContent` | `EntryContent` | Invalidate: `entries.get({ id })` | ### Subscription Mutations diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index 41a2c7b0..a3016ee6 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -130,6 +130,51 @@ export function upsertSubscriptionsInCollection( } } +/** + * Zeroes out unread counts for subscriptions matching markAllRead filters. + * + * - No filter: all subscriptions set to 0 + * - subscriptionId: only that subscription + * - tagId: all subscriptions whose `tags` array contains that tag + */ +export function zeroSubscriptionUnreadForMarkAllRead( + collections: Collections | null, + filters: { + subscriptionId?: string; + tagId?: string; + } +): void { + if (!collections) return; + + if (filters.subscriptionId) { + // Single subscription + const current = collections.subscriptions.get(filters.subscriptionId); + if (current) { + collections.subscriptions.update(filters.subscriptionId, (draft) => { + draft.unreadCount = 0; + }); + } + } else if (filters.tagId) { + // All subscriptions with this tag + collections.subscriptions.forEach((sub) => { + if (sub.tags.some((t) => t.id === filters.tagId) && sub.unreadCount > 0) { + collections.subscriptions.update(sub.id, (draft) => { + draft.unreadCount = 0; + }); + } + }); + } else { + // No filter: zero out all subscriptions + collections.subscriptions.forEach((sub) => { + if (sub.unreadCount > 0) { + collections.subscriptions.update(sub.id, (draft) => { + draft.unreadCount = 0; + }); + } + }); + } +} + // ============================================================================ // Tag Collection Writes (query-backed) // ============================================================================ diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 50686bc6..d966b09e 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -28,6 +28,7 @@ import { updateEntryReadInCollection, updateEntryStarredInCollection, updateEntryScoreInCollection, + zeroSubscriptionUnreadForMarkAllRead, } from "@/lib/collections/writes"; /** @@ -368,13 +369,19 @@ export function useEntryMutations(): UseEntryMutationsResult { // markAllRead mutation - invalidates caches and refreshes counts const markAllReadMutation = trpc.entries.markAllRead.useMutation({ - onSuccess: () => { + onSuccess: (_data, variables) => { utils.entries.list.invalidate(); collections.invalidateActiveView(); utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); collections.tags.utils.refetch(); + // Zero out unread counts for affected subscriptions in the local collection + zeroSubscriptionUnreadForMarkAllRead(collections, { + subscriptionId: variables.subscriptionId, + tagId: variables.tagId, + }); + // markAllRead doesn't return counts, so fetch fresh counts from server refreshGlobalCounts(utils, collections); }, From d875f9cad2736d535e8d60c641653871a22a4713 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 04:32:27 +0000 Subject: [PATCH 25/31] Clean up dead code and type safety issues (#625) - Remove dead `utils.entries.list.invalidate()` calls that targeted query keys no longer subscribed to after TanStack DB migration - Remove unsafe `as unknown as Subscription` type cast in handleSubscriptionCreated (SubscriptionData is structurally compatible) - Deduplicate EntryType definition: canonical source is now entries-list-input.ts, re-exported from useEntryMutations.ts - Remove unused `utils` variable in FileUploadButton Co-Authored-By: Claude Opus 4.6 --- src/components/entries/UnifiedEntriesContent.tsx | 2 +- src/components/saved/FileUploadButton.tsx | 4 +--- src/lib/cache/operations.ts | 10 +++------- src/lib/hooks/types.ts | 2 +- src/lib/hooks/useEntriesListInput.ts | 7 +++++-- src/lib/hooks/useEntryMutations.ts | 7 ++----- src/lib/hooks/useKeyboardShortcuts.ts | 2 +- tests/unit/frontend/cache/operations.test.ts | 10 ++++------ 8 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index 32fa61b9..c390d1a1 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -47,7 +47,7 @@ import { EntryNavigationProvider, useEntryNavigationState, } from "@/lib/hooks/useEntryNavigation"; -import { type EntryType } from "@/lib/hooks/useEntryMutations"; +import { type EntryType } from "@/lib/queries/entries-list-input"; /** * Route info derived from the current pathname. diff --git a/src/components/saved/FileUploadButton.tsx b/src/components/saved/FileUploadButton.tsx index dd2b6b53..34099505 100644 --- a/src/components/saved/FileUploadButton.tsx +++ b/src/components/saved/FileUploadButton.tsx @@ -68,13 +68,11 @@ export function FileUploadButton({ className = "", onSuccess }: FileUploadButton const [error, setError] = useState(null); const fileInputRef = useRef(null); - const utils = trpc.useUtils(); const collections = useCollections(); const uploadMutation = trpc.saved.uploadFile.useMutation({ onSuccess: () => { toast.success("File uploaded successfully"); - // Invalidate entries list and update counts in collection - utils.entries.list.invalidate({ type: "saved" }); + // Invalidate active entry view and update counts in collection collections.invalidateActiveView(); adjustEntriesCountInCollection(collections, "saved", 1, 1); adjustEntriesCountInCollection(collections, "all", 1, 1); diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index 8a70c224..21d08f98 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -3,11 +3,10 @@ * * Higher-level functions for subscription lifecycle and count updates. * All state updates flow through TanStack DB collections. - * React Query is only used for entries.list invalidation (pagination). */ import type { TRPCClientUtils } from "@/lib/trpc/client"; -import type { Collections, Subscription } from "@/lib/collections"; +import type { Collections } from "@/lib/collections"; import { calculateTagDeltasFromSubscriptions } from "./count-cache"; import { adjustSubscriptionUnreadInCollection, @@ -57,7 +56,7 @@ export function handleSubscriptionCreated( collections?: Collections | null ): void { // Add to TanStack DB subscriptions collection - addSubscriptionToCollection(collections ?? null, subscription as unknown as Subscription); + addSubscriptionToCollection(collections ?? null, subscription); // Update tag/uncategorized feedCount and unreadCount in collections if (subscription.tags.length === 0) { @@ -87,8 +86,6 @@ export function handleSubscriptionCreated( * - subscriptions collection (remove subscription) * - tags/uncategorized counts (feedCount + unreadCount) * - entries counts ("all") - * - * Also invalidates entries.list to refetch entry pages. */ export function handleSubscriptionDeleted( utils: TRPCClientUtils, @@ -123,8 +120,7 @@ export function handleSubscriptionDeleted( // Remove from collection removeSubscriptionFromCollection(collections ?? null, subscriptionId); - // Invalidate entries list - entries from this subscription should be filtered out - utils.entries.list.invalidate(); + // Invalidate active entry view - entries from this subscription should be filtered out collections?.invalidateActiveView(); } diff --git a/src/lib/hooks/types.ts b/src/lib/hooks/types.ts index cc250c3e..2f9913f4 100644 --- a/src/lib/hooks/types.ts +++ b/src/lib/hooks/types.ts @@ -4,7 +4,7 @@ * Common type definitions used across multiple hooks. */ -import { type EntryType } from "./useEntryMutations"; +import { type EntryType } from "@/lib/queries/entries-list-input"; /** * Entry data for list display and navigation. diff --git a/src/lib/hooks/useEntriesListInput.ts b/src/lib/hooks/useEntriesListInput.ts index 232e7c51..cd5f86df 100644 --- a/src/lib/hooks/useEntriesListInput.ts +++ b/src/lib/hooks/useEntriesListInput.ts @@ -10,8 +10,11 @@ import { useMemo } from "react"; import { usePathname } from "next/navigation"; import { useUrlViewPreferences } from "./useUrlViewPreferences"; -import { buildEntriesListInput, type EntriesListInput } from "@/lib/queries/entries-list-input"; -import { type EntryType } from "./useEntryMutations"; +import { + buildEntriesListInput, + type EntriesListInput, + type EntryType, +} from "@/lib/queries/entries-list-input"; /** * Route filters derived from the current pathname. diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index d966b09e..ebc79e86 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -30,11 +30,9 @@ import { updateEntryScoreInCollection, zeroSubscriptionUnreadForMarkAllRead, } from "@/lib/collections/writes"; +import { type EntryType } from "@/lib/queries/entries-list-input"; -/** - * Entry type for routing. - */ -export type EntryType = "web" | "email" | "saved"; +export type { EntryType }; /** * Options for the markAllRead mutation. @@ -370,7 +368,6 @@ export function useEntryMutations(): UseEntryMutationsResult { // markAllRead mutation - invalidates caches and refreshes counts const markAllReadMutation = trpc.entries.markAllRead.useMutation({ onSuccess: (_data, variables) => { - utils.entries.list.invalidate(); collections.invalidateActiveView(); utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); diff --git a/src/lib/hooks/useKeyboardShortcuts.ts b/src/lib/hooks/useKeyboardShortcuts.ts index d8bef051..cf9455fd 100644 --- a/src/lib/hooks/useKeyboardShortcuts.ts +++ b/src/lib/hooks/useKeyboardShortcuts.ts @@ -23,7 +23,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { type EntryType } from "./useEntryMutations"; +import { type EntryType } from "@/lib/queries/entries-list-input"; import { clientPush } from "@/lib/navigation"; /** diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index e69400de..bcb22c14 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -110,15 +110,13 @@ describe("handleSubscriptionCreated", () => { }); describe("handleSubscriptionDeleted", () => { - it("invalidates entries.list", () => { + it("does not perform React Query invalidations (uses collection writes instead)", () => { const mockUtils = createMockTrpcUtils(); handleSubscriptionDeleted(mockUtils.utils, "sub-1", null); - // entries.list invalidation is the only React Query operation remaining - const invalidateOps = mockUtils.operations.filter( - (op) => op.type === "invalidate" && op.router === "entries" && op.procedure === "list" - ); - expect(invalidateOps.length).toBe(1); + // With TanStack DB migration, handleSubscriptionDeleted uses collection writes + // and invalidateActiveView() instead of React Query invalidations + expect(mockUtils.operations.length).toBe(0); }); it("handles deletion without collections (no-op for collection writes)", () => { From 3cbf5a0a59588cb0c142c297914777f99a87007b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 04:42:34 +0000 Subject: [PATCH 26/31] Add unit tests for TanStack DB collection writes and count syncing Tests cover: - zeroSubscriptionUnreadForMarkAllRead (#624 fix): all filter modes (no filter, subscriptionId, tagId), already-zero subscriptions, null collections, and multi-tag edge cases - upsertEntriesInCollection: insert, update, mixed, null/empty - updateEntryReadInCollection: read/unread, missing entries, null - updateEntryStarredInCollection: star/unstar, missing, null - adjustEntriesCountInCollection: delta adjustments, floor at zero - setEntriesCountInCollection: create new and update existing records - adjustSubscriptionUnreadInCollection: deltas, floor at zero, bulk - set/setBulk subscription unread counts - add/remove/upsert subscriptions - updateEntryScoreInCollection and updateEntryMetadataInCollection - uncategorized count adjustments (unread and feed count) - createCollections count seeding from SSR-prefetched data (#622 fix) - Query cache subscription syncing counts on async updates - Cleanup function stops syncing All tests use real TanStack DB collections (not mocks). 95 tests total. Co-Authored-By: Claude Opus 4.6 --- .../frontend/collections/count-sync.test.ts | 344 +++++++ .../unit/frontend/collections/writes.test.ts | 888 ++++++++++++++++++ 2 files changed, 1232 insertions(+) create mode 100644 tests/unit/frontend/collections/count-sync.test.ts create mode 100644 tests/unit/frontend/collections/writes.test.ts diff --git a/tests/unit/frontend/collections/count-sync.test.ts b/tests/unit/frontend/collections/count-sync.test.ts new file mode 100644 index 00000000..f1b66d97 --- /dev/null +++ b/tests/unit/frontend/collections/count-sync.test.ts @@ -0,0 +1,344 @@ +/** + * Unit tests for count syncing logic in createCollections. + * + * Tests verify that createCollections properly: + * 1. Seeds counts from SSR-prefetched data when available + * 2. Seeds uncategorized counts from prefetched tags.list data + * 3. The query cache subscription syncs counts when entries.count queries + * resolve after initialization + * + * Uses a real QueryClient and real TanStack DB collections. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { createCollections, type CreateCollectionsResult } from "@/lib/collections"; +import { TRPC_TAGS_LIST_KEY, type TagsListResponse } from "@/lib/collections/tags"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** tRPC query key for tags.list (no input) */ +const TAGS_LIST_KEY = TRPC_TAGS_LIST_KEY as unknown as readonly unknown[]; + +/** Build a tRPC entries.count query key with optional input */ +function entriesCountKey(input?: Record): readonly unknown[] { + return [["entries", "count"], { input: input ?? {}, type: "query" }]; +} + +/** Build a mock TagsListResponse */ +function mockTagsListResponse(overrides?: Partial): TagsListResponse { + return { + items: [], + uncategorized: { feedCount: 5, unreadCount: 3 }, + ...overrides, + }; +} + +/** + * Creates a test QueryClient, calls createCollections, and returns both. + * The cleanup function is tracked for proper teardown. + */ +function setup(prefetch?: (queryClient: QueryClient) => void): { + queryClient: QueryClient; + result: CreateCollectionsResult; +} { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Disable retries and refetching for tests + retry: false, + staleTime: Infinity, + }, + }, + }); + + if (prefetch) { + prefetch(queryClient); + } + + const result = createCollections(queryClient, { + fetchTagsAndUncategorized: async () => mockTagsListResponse(), + }); + + return { queryClient, result }; +} + +// Track created collections for cleanup +const createdResults: CreateCollectionsResult[] = []; + +function setupTracked(prefetch?: (queryClient: QueryClient) => void): ReturnType { + const s = setup(prefetch); + createdResults.push(s.result); + return s; +} + +afterEach(() => { + // Clean up all created collections + for (const result of createdResults) { + result.cleanup(); + } + createdResults.length = 0; +}); + +// ============================================================================ +// SSR Prefetch Seeding - Uncategorized Counts +// ============================================================================ + +describe("SSR seeding: uncategorized counts from tags.list", () => { + it("seeds uncategorized counts when tags.list data is prefetched", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 12, unreadCount: 7 }, + }) + ); + }); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeDefined(); + expect(uncategorized?.total).toBe(12); + expect(uncategorized?.unread).toBe(7); + }); + + it("does not seed uncategorized counts when no tags.list data is prefetched", () => { + const { result } = setupTracked(); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeUndefined(); + }); +}); + +// ============================================================================ +// SSR Prefetch Seeding - Entry Counts +// ============================================================================ + +describe("SSR seeding: entry counts from entries.count", () => { + it("seeds 'all' count from prefetched entries.count with empty input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + }); + + const allCount = result.collections.counts.get("all"); + expect(allCount).toBeDefined(); + expect(allCount?.total).toBe(100); + expect(allCount?.unread).toBe(50); + }); + + it("seeds 'starred' count from prefetched entries.count with starredOnly input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({ starredOnly: true }), { total: 10, unread: 5 }); + }); + + const starredCount = result.collections.counts.get("starred"); + expect(starredCount).toBeDefined(); + expect(starredCount?.total).toBe(10); + expect(starredCount?.unread).toBe(5); + }); + + it("seeds 'saved' count from prefetched entries.count with type=saved input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({ type: "saved" }), { total: 20, unread: 10 }); + }); + + const savedCount = result.collections.counts.get("saved"); + expect(savedCount).toBeDefined(); + expect(savedCount?.total).toBe(20); + expect(savedCount?.unread).toBe(10); + }); + + it("seeds multiple count categories from prefetched data", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + qc.setQueryData(entriesCountKey({ starredOnly: true }), { total: 10, unread: 5 }); + qc.setQueryData(entriesCountKey({ type: "saved" }), { total: 20, unread: 10 }); + }); + + expect(result.collections.counts.get("all")?.total).toBe(100); + expect(result.collections.counts.get("starred")?.total).toBe(10); + expect(result.collections.counts.get("saved")?.total).toBe(20); + }); + + it("ignores entries.count queries with unrecognized input (e.g. subscriptionId filter)", () => { + const { result } = setupTracked((qc) => { + // This has a subscriptionId filter, which doesn't map to all/starred/saved + qc.setQueryData(entriesCountKey({ subscriptionId: "sub-1" }), { total: 30, unread: 15 }); + }); + + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); + + it("does not seed any counts when no entries.count data is prefetched", () => { + const { result } = setupTracked(); + + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); +}); + +// ============================================================================ +// Query Cache Subscription - Async Count Updates +// ============================================================================ + +describe("query cache subscription: entries.count updates", () => { + it("syncs 'all' count when entries.count query succeeds after initialization", async () => { + const { queryClient, result } = setupTracked(); + + // Simulate a query resolving after createCollections was called + // by setting query data and triggering the cache subscription + queryClient.setQueryData(entriesCountKey({}), { total: 200, unread: 80 }); + + // The cache subscription fires synchronously in setQueryData + const allCount = result.collections.counts.get("all"); + expect(allCount).toBeDefined(); + expect(allCount?.total).toBe(200); + expect(allCount?.unread).toBe(80); + }); + + it("syncs 'starred' count when entries.count query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ starredOnly: true }), { total: 15, unread: 8 }); + + const starredCount = result.collections.counts.get("starred"); + expect(starredCount).toBeDefined(); + expect(starredCount?.total).toBe(15); + expect(starredCount?.unread).toBe(8); + }); + + it("syncs 'saved' count when entries.count query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ type: "saved" }), { total: 25, unread: 12 }); + + const savedCount = result.collections.counts.get("saved"); + expect(savedCount).toBeDefined(); + expect(savedCount?.total).toBe(25); + expect(savedCount?.unread).toBe(12); + }); + + it("updates existing count when new data arrives", () => { + const { queryClient, result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + }); + + expect(result.collections.counts.get("all")?.total).toBe(100); + + // Simulate a refetch with updated data + queryClient.setQueryData(entriesCountKey({}), { total: 120, unread: 60 }); + + expect(result.collections.counts.get("all")?.total).toBe(120); + expect(result.collections.counts.get("all")?.unread).toBe(60); + }); + + it("ignores entries.count queries with unrecognized input", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ subscriptionId: "sub-1" }), { + total: 30, + unread: 15, + }); + + // Should not have created any known count keys + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); +}); + +// ============================================================================ +// Query Cache Subscription - Uncategorized Count Updates +// ============================================================================ + +describe("query cache subscription: uncategorized counts from tags.list", () => { + it("syncs uncategorized counts when tags.list query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 8, unreadCount: 4 }, + }) + ); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeDefined(); + expect(uncategorized?.total).toBe(8); + expect(uncategorized?.unread).toBe(4); + }); + + it("updates existing uncategorized counts on tags.list refetch", () => { + const { queryClient, result } = setupTracked((qc) => { + qc.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 5, unreadCount: 3 }, + }) + ); + }); + + expect(result.collections.counts.get("uncategorized")?.total).toBe(5); + + // Simulate refetch with updated data + queryClient.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 6, unreadCount: 2 }, + }) + ); + + expect(result.collections.counts.get("uncategorized")?.total).toBe(6); + expect(result.collections.counts.get("uncategorized")?.unread).toBe(2); + }); +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +describe("cleanup", () => { + it("stops syncing counts after cleanup is called", () => { + const { queryClient, result } = setupTracked(); + + // Calling cleanup should unsubscribe from query cache + result.cleanup(); + + // Now set data -- should NOT sync to counts collection + queryClient.setQueryData(entriesCountKey({}), { total: 999, unread: 999 }); + + expect(result.collections.counts.has("all")).toBe(false); + }); +}); + +// ============================================================================ +// Collections structure +// ============================================================================ + +describe("createCollections structure", () => { + it("creates all expected collection types", () => { + const { result } = setupTracked(); + + expect(result.collections.subscriptions).toBeDefined(); + expect(result.collections.tags).toBeDefined(); + expect(result.collections.entries).toBeDefined(); + expect(result.collections.counts).toBeDefined(); + expect(result.collections.activeViewCollection).toBeNull(); + expect(typeof result.collections.invalidateActiveView).toBe("function"); + }); + + it("returns a cleanup function", () => { + const { result } = setupTracked(); + expect(typeof result.cleanup).toBe("function"); + }); + + it("invalidateActiveView is a no-op by default", () => { + const { result } = setupTracked(); + // Should not throw + result.collections.invalidateActiveView(); + }); +}); diff --git a/tests/unit/frontend/collections/writes.test.ts b/tests/unit/frontend/collections/writes.test.ts new file mode 100644 index 00000000..6db9c7e6 --- /dev/null +++ b/tests/unit/frontend/collections/writes.test.ts @@ -0,0 +1,888 @@ +/** + * Unit tests for TanStack DB collection write functions. + * + * Tests use real TanStack DB collections (local-only) to verify that + * collection writes behave correctly for optimistic updates, SSE handling, + * and mark-all-read operations. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { createSubscriptionsCollection } from "@/lib/collections/subscriptions"; +import { createEntriesCollection } from "@/lib/collections/entries"; +import { createCountsCollection } from "@/lib/collections/counts"; +import type { Collections } from "@/lib/collections"; +import type { Subscription, EntryListItem } from "@/lib/collections/types"; +import { + zeroSubscriptionUnreadForMarkAllRead, + upsertEntriesInCollection, + updateEntryReadInCollection, + updateEntryStarredInCollection, + adjustEntriesCountInCollection, + setEntriesCountInCollection, + adjustSubscriptionUnreadInCollection, + setSubscriptionUnreadInCollection, + setBulkSubscriptionUnreadInCollection, + addSubscriptionToCollection, + removeSubscriptionFromCollection, + upsertSubscriptionsInCollection, + updateEntryScoreInCollection, + updateEntryMetadataInCollection, + adjustUncategorizedUnreadInCollection, + adjustUncategorizedFeedCountInCollection, + setUncategorizedUnreadInCollection, +} from "@/lib/collections/writes"; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +function createTestSubscription(overrides: Partial = {}): Subscription { + return { + id: "sub-1", + type: "web", + url: "https://example.com/feed.xml", + title: "Example Feed", + originalTitle: "Example Feed", + description: "An example feed", + siteUrl: "https://example.com", + subscribedAt: new Date("2024-01-01"), + unreadCount: 10, + totalCount: 50, + tags: [], + fetchFullContent: false, + ...overrides, + } as Subscription; +} + +function createTestEntry(overrides: Partial = {}): EntryListItem { + return { + id: "entry-1", + subscriptionId: "sub-1", + feedId: "feed-1", + type: "web", + url: "https://example.com/post-1", + title: "Test Post", + author: "Author", + summary: "A test post", + publishedAt: new Date("2024-06-01"), + fetchedAt: new Date("2024-06-01"), + updatedAt: new Date("2024-06-01"), + read: false, + starred: false, + feedTitle: "Example Feed", + siteName: "example.com", + score: null, + implicitScore: 0, + predictedScore: null, + ...overrides, + } as EntryListItem; +} + +/** + * Creates a minimal Collections object with real local-only collections. + * Tags collection is omitted since it requires a QueryClient; tests that + * need tags should create one separately. + */ +function createTestCollections(): Collections { + return { + subscriptions: createSubscriptionsCollection(), + // Tags requires a QueryClient; cast a stub for tests that don't use it + tags: undefined as unknown as Collections["tags"], + entries: createEntriesCollection(), + counts: createCountsCollection(), + activeViewCollection: null, + invalidateActiveView: () => {}, + }; +} + +// ============================================================================ +// zeroSubscriptionUnreadForMarkAllRead +// ============================================================================ + +describe("zeroSubscriptionUnreadForMarkAllRead", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("zeroes all subscriptions when no filter is provided", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-3", unreadCount: 20 })); + + zeroSubscriptionUnreadForMarkAllRead(collections, {}); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(0); + }); + + it("zeroes only the specified subscription when subscriptionId filter is provided", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + zeroSubscriptionUnreadForMarkAllRead(collections, { subscriptionId: "sub-1" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); + }); + + it("zeroes subscriptions matching the tagId filter", () => { + const tag = { id: "tag-1", name: "News", color: "#ff0000" }; + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [tag] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-2", unreadCount: 5, tags: [tag] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-3", unreadCount: 20, tags: [] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "tag-1" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + // Sub-3 has no matching tag, should be untouched + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(20); + }); + + it("does not touch subscriptions that already have zero unread count", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 0 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + // This should not throw or cause issues for already-zero subscriptions + zeroSubscriptionUnreadForMarkAllRead(collections, {}); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + }); + + it("is a no-op when collections is null", () => { + // Should not throw + zeroSubscriptionUnreadForMarkAllRead(null, {}); + zeroSubscriptionUnreadForMarkAllRead(null, { subscriptionId: "sub-1" }); + zeroSubscriptionUnreadForMarkAllRead(null, { tagId: "tag-1" }); + }); + + it("handles subscriptionId that does not exist in the collection", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + // Should not throw for non-existent subscription + zeroSubscriptionUnreadForMarkAllRead(collections, { subscriptionId: "non-existent" }); + + // Existing subscription should be untouched + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("handles tagId with no matching subscriptions", () => { + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "non-existent-tag" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("only zeroes subscriptions whose tag array contains the specified tag", () => { + const tagA = { id: "tag-a", name: "Tag A", color: null }; + const tagB = { id: "tag-b", name: "Tag B", color: null }; + + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [tagA, tagB] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-2", unreadCount: 5, tags: [tagB] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-3", unreadCount: 3, tags: [tagA] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "tag-a" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); // only has tag-b + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(0); + }); +}); + +// ============================================================================ +// upsertEntriesInCollection +// ============================================================================ + +describe("upsertEntriesInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("inserts new entries into the global entries collection", () => { + const entry1 = createTestEntry({ id: "entry-1" }); + const entry2 = createTestEntry({ id: "entry-2", title: "Second Post" }); + + upsertEntriesInCollection(collections, [entry1, entry2]); + + expect(collections.entries.has("entry-1")).toBe(true); + expect(collections.entries.has("entry-2")).toBe(true); + expect(collections.entries.get("entry-1")?.title).toBe("Test Post"); + expect(collections.entries.get("entry-2")?.title).toBe("Second Post"); + }); + + it("updates existing entries in the collection", () => { + const entry = createTestEntry({ id: "entry-1", title: "Original Title", read: false }); + collections.entries.insert(entry); + + const updatedEntry = createTestEntry({ id: "entry-1", title: "Updated Title", read: true }); + upsertEntriesInCollection(collections, [updatedEntry]); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated Title"); + expect(collections.entries.get("entry-1")?.read).toBe(true); + }); + + it("handles a mix of new and existing entries", () => { + const existing = createTestEntry({ id: "entry-1", title: "Existing" }); + collections.entries.insert(existing); + + const updatedExisting = createTestEntry({ id: "entry-1", title: "Updated Existing" }); + const newEntry = createTestEntry({ id: "entry-2", title: "Brand New" }); + + upsertEntriesInCollection(collections, [updatedExisting, newEntry]); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated Existing"); + expect(collections.entries.get("entry-2")?.title).toBe("Brand New"); + }); + + it("is a no-op when collections is null", () => { + upsertEntriesInCollection(null, [createTestEntry()]); + // Should not throw + }); + + it("is a no-op for empty entries array", () => { + upsertEntriesInCollection(collections, []); + expect(collections.entries.size).toBe(0); + }); +}); + +// ============================================================================ +// updateEntryReadInCollection +// ============================================================================ + +describe("updateEntryReadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("marks entries as read in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + collections.entries.insert(createTestEntry({ id: "entry-2", read: false })); + + updateEntryReadInCollection(collections, ["entry-1", "entry-2"], true); + + expect(collections.entries.get("entry-1")?.read).toBe(true); + expect(collections.entries.get("entry-2")?.read).toBe(true); + }); + + it("marks entries as unread in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: true })); + + updateEntryReadInCollection(collections, ["entry-1"], false); + + expect(collections.entries.get("entry-1")?.read).toBe(false); + }); + + it("skips entries that do not exist in the collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + + // "entry-2" does not exist, should not throw + updateEntryReadInCollection(collections, ["entry-1", "entry-2"], true); + + expect(collections.entries.get("entry-1")?.read).toBe(true); + expect(collections.entries.has("entry-2")).toBe(false); + }); + + it("is a no-op when collections is null", () => { + updateEntryReadInCollection(null, ["entry-1"], true); + // Should not throw + }); + + it("is a no-op for empty entryIds array", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + + updateEntryReadInCollection(collections, [], true); + + expect(collections.entries.get("entry-1")?.read).toBe(false); + }); +}); + +// ============================================================================ +// updateEntryStarredInCollection +// ============================================================================ + +describe("updateEntryStarredInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("stars an entry in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", starred: false })); + + updateEntryStarredInCollection(collections, "entry-1", true); + + expect(collections.entries.get("entry-1")?.starred).toBe(true); + }); + + it("unstars an entry in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", starred: true })); + + updateEntryStarredInCollection(collections, "entry-1", false); + + expect(collections.entries.get("entry-1")?.starred).toBe(false); + }); + + it("skips entry that does not exist in the collection", () => { + // Should not throw + updateEntryStarredInCollection(collections, "non-existent", true); + }); + + it("is a no-op when collections is null", () => { + updateEntryStarredInCollection(null, "entry-1", true); + // Should not throw + }); +}); + +// ============================================================================ +// adjustEntriesCountInCollection +// ============================================================================ + +describe("adjustEntriesCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adjusts total and unread counts for an existing count record", () => { + collections.counts.insert({ id: "all", total: 100, unread: 50 }); + + adjustEntriesCountInCollection(collections, "all", 1, 1); + + expect(collections.counts.get("all")?.total).toBe(101); + expect(collections.counts.get("all")?.unread).toBe(51); + }); + + it("decreases counts but floors at zero", () => { + collections.counts.insert({ id: "all", total: 1, unread: 0 }); + + adjustEntriesCountInCollection(collections, "all", -5, -5); + + expect(collections.counts.get("all")?.total).toBe(0); + expect(collections.counts.get("all")?.unread).toBe(0); + }); + + it("does not create a count record that does not exist", () => { + // "all" not inserted, so this should be a no-op + adjustEntriesCountInCollection(collections, "all", 1, 1); + + expect(collections.counts.has("all")).toBe(false); + }); + + it("is a no-op when both deltas are zero", () => { + collections.counts.insert({ id: "all", total: 100, unread: 50 }); + + adjustEntriesCountInCollection(collections, "all", 0, 0); + + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("is a no-op when collections is null", () => { + adjustEntriesCountInCollection(null, "all", 1, 1); + // Should not throw + }); + + it("works with starred and saved keys", () => { + collections.counts.insert({ id: "starred", total: 10, unread: 5 }); + collections.counts.insert({ id: "saved", total: 20, unread: 10 }); + + adjustEntriesCountInCollection(collections, "starred", 2, 1); + adjustEntriesCountInCollection(collections, "saved", -3, -2); + + expect(collections.counts.get("starred")?.total).toBe(12); + expect(collections.counts.get("starred")?.unread).toBe(6); + expect(collections.counts.get("saved")?.total).toBe(17); + expect(collections.counts.get("saved")?.unread).toBe(8); + }); +}); + +// ============================================================================ +// setEntriesCountInCollection +// ============================================================================ + +describe("setEntriesCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("creates a count record that does not exist yet", () => { + setEntriesCountInCollection(collections, "all", 100, 50); + + expect(collections.counts.has("all")).toBe(true); + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("updates an existing count record", () => { + collections.counts.insert({ id: "all", total: 50, unread: 25 }); + + setEntriesCountInCollection(collections, "all", 100, 50); + + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("is a no-op when collections is null", () => { + setEntriesCountInCollection(null, "all", 100, 50); + // Should not throw + }); +}); + +// ============================================================================ +// adjustSubscriptionUnreadInCollection +// ============================================================================ + +describe("adjustSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adjusts unread count for subscriptions by delta", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + const deltas = new Map([["sub-1", -3]]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(7); + }); + + it("floors unread count at zero", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 2 })); + + const deltas = new Map([["sub-1", -10]]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + }); + + it("handles multiple subscriptions at once", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + const deltas = new Map([ + ["sub-1", -2], + ["sub-2", 3], + ]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(8); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(8); + }); + + it("skips subscriptions not in the collection", () => { + const deltas = new Map([["non-existent", -1]]); + // Should not throw + adjustSubscriptionUnreadInCollection(collections, deltas); + }); + + it("is a no-op when collections is null", () => { + adjustSubscriptionUnreadInCollection(null, new Map([["sub-1", 1]])); + }); + + it("is a no-op for empty deltas map", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + adjustSubscriptionUnreadInCollection(collections, new Map()); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); +}); + +// ============================================================================ +// setSubscriptionUnreadInCollection +// ============================================================================ + +describe("setSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("sets absolute unread count for a subscription", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + setSubscriptionUnreadInCollection(collections, "sub-1", 42); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(42); + }); + + it("skips subscription not in collection", () => { + setSubscriptionUnreadInCollection(collections, "non-existent", 5); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + setSubscriptionUnreadInCollection(null, "sub-1", 5); + }); +}); + +// ============================================================================ +// setBulkSubscriptionUnreadInCollection +// ============================================================================ + +describe("setBulkSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("sets absolute unread counts for multiple subscriptions", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 20 })); + + const updates = new Map([ + ["sub-1", 0], + ["sub-2", 5], + ]); + setBulkSubscriptionUnreadInCollection(collections, updates); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); + }); + + it("skips subscriptions not in collection", () => { + const updates = new Map([["non-existent", 5]]); + setBulkSubscriptionUnreadInCollection(collections, updates); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + setBulkSubscriptionUnreadInCollection(null, new Map([["sub-1", 5]])); + }); + + it("is a no-op for empty updates map", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + setBulkSubscriptionUnreadInCollection(collections, new Map()); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); +}); + +// ============================================================================ +// addSubscriptionToCollection +// ============================================================================ + +describe("addSubscriptionToCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adds a new subscription to the collection", () => { + const sub = createTestSubscription({ id: "sub-1" }); + addSubscriptionToCollection(collections, sub); + + expect(collections.subscriptions.has("sub-1")).toBe(true); + expect(collections.subscriptions.get("sub-1")?.title).toBe("Example Feed"); + }); + + it("skips duplicate subscriptions (SSE race condition protection)", () => { + const sub = createTestSubscription({ id: "sub-1", unreadCount: 10 }); + collections.subscriptions.insert(sub); + + // Attempt to add the same subscription again with different data + const duplicate = createTestSubscription({ id: "sub-1", unreadCount: 99 }); + addSubscriptionToCollection(collections, duplicate); + + // Original should be preserved + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("is a no-op when collections is null", () => { + addSubscriptionToCollection(null, createTestSubscription()); + }); +}); + +// ============================================================================ +// removeSubscriptionFromCollection +// ============================================================================ + +describe("removeSubscriptionFromCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("removes a subscription from the collection", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1" })); + + removeSubscriptionFromCollection(collections, "sub-1"); + + expect(collections.subscriptions.has("sub-1")).toBe(false); + }); + + it("is a no-op for non-existent subscription", () => { + removeSubscriptionFromCollection(collections, "non-existent"); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + removeSubscriptionFromCollection(null, "sub-1"); + }); +}); + +// ============================================================================ +// upsertSubscriptionsInCollection +// ============================================================================ + +describe("upsertSubscriptionsInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("inserts new subscriptions into the collection", () => { + const sub1 = createTestSubscription({ id: "sub-1" }); + const sub2 = createTestSubscription({ id: "sub-2", title: "Second Feed" }); + + upsertSubscriptionsInCollection(collections, [sub1, sub2]); + + expect(collections.subscriptions.has("sub-1")).toBe(true); + expect(collections.subscriptions.has("sub-2")).toBe(true); + expect(collections.subscriptions.get("sub-2")?.title).toBe("Second Feed"); + }); + + it("updates existing subscriptions in the collection", () => { + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", title: "Old Title", unreadCount: 5 }) + ); + + const updated = createTestSubscription({ id: "sub-1", title: "New Title", unreadCount: 10 }); + upsertSubscriptionsInCollection(collections, [updated]); + + expect(collections.subscriptions.get("sub-1")?.title).toBe("New Title"); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("handles a mix of new and existing subscriptions", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", title: "Existing" })); + + const updatedExisting = createTestSubscription({ id: "sub-1", title: "Updated" }); + const newSub = createTestSubscription({ id: "sub-2", title: "New" }); + + upsertSubscriptionsInCollection(collections, [updatedExisting, newSub]); + + expect(collections.subscriptions.get("sub-1")?.title).toBe("Updated"); + expect(collections.subscriptions.get("sub-2")?.title).toBe("New"); + }); + + it("is a no-op for empty array", () => { + upsertSubscriptionsInCollection(collections, []); + expect(collections.subscriptions.size).toBe(0); + }); + + it("is a no-op when collections is null", () => { + upsertSubscriptionsInCollection(null, [createTestSubscription()]); + }); +}); + +// ============================================================================ +// updateEntryScoreInCollection +// ============================================================================ + +describe("updateEntryScoreInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("updates score fields for an entry", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", score: null, implicitScore: 0 })); + + updateEntryScoreInCollection(collections, "entry-1", 2, 2); + + expect(collections.entries.get("entry-1")?.score).toBe(2); + expect(collections.entries.get("entry-1")?.implicitScore).toBe(2); + }); + + it("clears score to null", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", score: 2, implicitScore: 2 })); + + updateEntryScoreInCollection(collections, "entry-1", null, 0); + + expect(collections.entries.get("entry-1")?.score).toBeNull(); + expect(collections.entries.get("entry-1")?.implicitScore).toBe(0); + }); + + it("skips entry that does not exist", () => { + updateEntryScoreInCollection(collections, "non-existent", 1, 1); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + updateEntryScoreInCollection(null, "entry-1", 1, 1); + }); +}); + +// ============================================================================ +// updateEntryMetadataInCollection +// ============================================================================ + +describe("updateEntryMetadataInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("updates metadata fields for an entry", () => { + collections.entries.insert( + createTestEntry({ id: "entry-1", title: "Old Title", author: "Old Author" }) + ); + + updateEntryMetadataInCollection(collections, "entry-1", { + title: "New Title", + author: "New Author", + }); + + expect(collections.entries.get("entry-1")?.title).toBe("New Title"); + expect(collections.entries.get("entry-1")?.author).toBe("New Author"); + }); + + it("partially updates metadata", () => { + collections.entries.insert( + createTestEntry({ id: "entry-1", title: "Original", summary: "Original Summary" }) + ); + + updateEntryMetadataInCollection(collections, "entry-1", { title: "Updated" }); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated"); + expect(collections.entries.get("entry-1")?.summary).toBe("Original Summary"); + }); + + it("skips entry that does not exist", () => { + updateEntryMetadataInCollection(collections, "non-existent", { title: "New" }); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + updateEntryMetadataInCollection(null, "entry-1", { title: "New" }); + }); +}); + +// ============================================================================ +// Uncategorized Count Writes +// ============================================================================ + +describe("adjustUncategorizedUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("adjusts uncategorized unread count by delta", () => { + adjustUncategorizedUnreadInCollection(collections, -1); + expect(collections.counts.get("uncategorized")?.unread).toBe(2); + }); + + it("floors unread count at zero", () => { + adjustUncategorizedUnreadInCollection(collections, -10); + expect(collections.counts.get("uncategorized")?.unread).toBe(0); + }); + + it("is a no-op when delta is zero", () => { + adjustUncategorizedUnreadInCollection(collections, 0); + expect(collections.counts.get("uncategorized")?.unread).toBe(3); + }); + + it("is a no-op when collections is null", () => { + adjustUncategorizedUnreadInCollection(null, -1); + }); + + it("is a no-op when uncategorized record does not exist", () => { + const freshCollections = createTestCollections(); + adjustUncategorizedUnreadInCollection(freshCollections, -1); + expect(freshCollections.counts.has("uncategorized")).toBe(false); + }); +}); + +describe("adjustUncategorizedFeedCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("adjusts uncategorized feed count by delta", () => { + adjustUncategorizedFeedCountInCollection(collections, 1); + expect(collections.counts.get("uncategorized")?.total).toBe(6); + }); + + it("floors feed count at zero", () => { + adjustUncategorizedFeedCountInCollection(collections, -10); + expect(collections.counts.get("uncategorized")?.total).toBe(0); + }); + + it("is a no-op when delta is zero", () => { + adjustUncategorizedFeedCountInCollection(collections, 0); + expect(collections.counts.get("uncategorized")?.total).toBe(5); + }); + + it("is a no-op when collections is null", () => { + adjustUncategorizedFeedCountInCollection(null, 1); + }); +}); + +describe("setUncategorizedUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("sets absolute uncategorized unread count", () => { + setUncategorizedUnreadInCollection(collections, 10); + expect(collections.counts.get("uncategorized")?.unread).toBe(10); + }); + + it("does not affect total count", () => { + setUncategorizedUnreadInCollection(collections, 10); + expect(collections.counts.get("uncategorized")?.total).toBe(5); + }); + + it("is a no-op when uncategorized record does not exist", () => { + const freshCollections = createTestCollections(); + setUncategorizedUnreadInCollection(freshCollections, 10); + expect(freshCollections.counts.has("uncategorized")).toBe(false); + }); + + it("is a no-op when collections is null", () => { + setUncategorizedUnreadInCollection(null, 10); + }); +}); From b6fe20ab3b680d8e3995deaa56d4d972280d9069 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 05:04:23 +0000 Subject: [PATCH 27/31] Make feedType required on new_entry events (#623) All callers of publishNewEntry() already provide feedType. Make it required in the server function signature, the NewEntryEvent type, the client Zod schema, and the SSE serialization. This removes the need for a client-side fallback. Co-Authored-By: Claude Opus 4.6 --- src/app/api/v1/events/route.ts | 8 ++++---- src/lib/cache/event-handlers.ts | 6 ++---- src/lib/events/schemas.ts | 2 +- src/server/redis/pubsub.ts | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index 474cd0e7..1b241323 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -379,18 +379,18 @@ export async function GET(req: Request): Promise { const subscriptionId = feedSubscriptionMap.get(event.feedId) ?? null; // Transform event to use subscriptionId instead of feedId - // Include metadata for entry_updated events to enable direct cache updates + // Include type-specific fields for direct cache updates const clientEvent: Record = { type: event.type, subscriptionId, entryId: event.entryId, timestamp: event.timestamp, updatedAt: event.updatedAt, // Database updated_at for cursor tracking - feedType: event.feedType, }; - // Include metadata for entry_updated events - if (event.type === "entry_updated") { + if (event.type === "new_entry") { + clientEvent.feedType = event.feedType; + } else if (event.type === "entry_updated") { clientEvent.metadata = event.metadata; } diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index c98c7e91..42ac645e 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -51,10 +51,8 @@ export function handleSyncEvent( ): void { switch (event.type) { case "new_entry": - // Update unread counts in collections. - // feedType is optional in the SSE schema (older servers may not send it). - // Default to "web" when missing so count updates still happen. - handleNewEntry(utils, event.subscriptionId, event.feedType ?? "web", collections); + // Update unread counts in collections + handleNewEntry(utils, event.subscriptionId, event.feedType, collections); // Invalidate view collection so the new entry appears in the list collections?.invalidateActiveView(); break; diff --git a/src/lib/events/schemas.ts b/src/lib/events/schemas.ts index e57b8052..11a6db01 100644 --- a/src/lib/events/schemas.ts +++ b/src/lib/events/schemas.ts @@ -93,7 +93,7 @@ export const newEntryEventSchema = z.object({ entryId: z.string(), timestamp: timestampWithDefault, updatedAt: z.string(), - feedType: z.enum(["web", "email", "saved"]).optional(), + feedType: z.enum(["web", "email", "saved"]), }); export const entryUpdatedEventSchema = z.object({ diff --git a/src/server/redis/pubsub.ts b/src/server/redis/pubsub.ts index 6ca43c62..bca53aed 100644 --- a/src/server/redis/pubsub.ts +++ b/src/server/redis/pubsub.ts @@ -24,7 +24,6 @@ interface BaseFeedEvent { timestamp: string; /** Database updated_at for cursor tracking (entries cursor) */ updatedAt: string; - feedType?: "web" | "email" | "saved"; // Added to new_entry for cache updates } /** @@ -32,6 +31,7 @@ interface BaseFeedEvent { */ export interface NewEntryEvent extends BaseFeedEvent { type: "new_entry"; + feedType: "web" | "email" | "saved"; } /** @@ -333,7 +333,7 @@ export async function publishNewEntry( feedId: string, entryId: string, updatedAt: Date, - feedType?: "web" | "email" | "saved" + feedType: "web" | "email" | "saved" ): Promise { const event: NewEntryEvent = { type: "new_entry", From 87b934fcaae253d5d416fb84043879d08b5902d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 05:20:26 +0000 Subject: [PATCH 28/31] Fix sidebar subscription unread counts not updating in tag sections Per-tag subscription collections (rendered by TagSubscriptionList) were independent from the global subscriptions collection. When mutations updated unread counts in the global collection, the per-tag collections never received the update, so sidebar counts inside expanded tag sections stayed stale. Fix by tracking active tag subscription collections in a Map on the Collections object. TagSubscriptionList registers/unregisters its collection on mount/unmount. All subscription write functions now propagate changes to every registered tag subscription collection. Co-Authored-By: Claude Opus 4.6 --- src/components/layout/TagSubscriptionList.tsx | 9 +++ src/lib/collections/index.ts | 12 +++- src/lib/collections/writes.ts | 69 +++++++++++++++++++ .../unit/frontend/collections/writes.test.ts | 1 + 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/components/layout/TagSubscriptionList.tsx b/src/components/layout/TagSubscriptionList.tsx index 9ed69486..962c1bb4 100644 --- a/src/components/layout/TagSubscriptionList.tsx +++ b/src/components/layout/TagSubscriptionList.tsx @@ -63,6 +63,15 @@ export function TagSubscriptionList({ // Create the on-demand collection (recreates on filter change) const { collection: tagCollection, filterKey } = useTagSubscriptionsCollection(filters); + // Register the tag subscription collection so write functions can propagate + // unread count changes to it (in addition to the global subscriptions collection) + useEffect(() => { + collections.tagSubscriptionCollections.set(filterKey, tagCollection); + return () => { + collections.tagSubscriptionCollections.delete(filterKey); + }; + }, [collections, filterKey, tagCollection]); + // Live infinite query over the tag subscription collection // Server sorts alphabetically by title, so we use ID as the orderBy // (UUIDv7 gives us stable ordering; the server determines the actual sort) diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts index 30968b53..502a97dc 100644 --- a/src/lib/collections/index.ts +++ b/src/lib/collections/index.ts @@ -18,7 +18,11 @@ */ import type { QueryClient, QueryCacheNotifyEvent } from "@tanstack/react-query"; -import { createSubscriptionsCollection, type SubscriptionsCollection } from "./subscriptions"; +import { + createSubscriptionsCollection, + type SubscriptionsCollection, + type TagSubscriptionsCollection, +} from "./subscriptions"; import { createTagsCollection, type TagsCollection, @@ -46,7 +50,7 @@ export type { CountRecord, UncategorizedCounts, } from "./types"; -export type { SubscriptionsCollection } from "./subscriptions"; +export type { SubscriptionsCollection, TagSubscriptionsCollection } from "./subscriptions"; export type { TagsCollection } from "./tags"; export type { EntriesCollection } from "./entries"; export type { ViewEntriesCollection } from "./entries"; @@ -64,6 +68,9 @@ export interface Collections { tags: TagsCollection; entries: EntriesCollection; counts: CountsCollection; + /** Active per-tag subscription collections, keyed by filter key. + * Registered by TagSubscriptionList, used by writes to propagate unread count changes. */ + tagSubscriptionCollections: Map; /** The active on-demand view collection, set by useViewEntriesCollection */ activeViewCollection: ViewEntriesCollection | null; /** @@ -210,6 +217,7 @@ export function createCollections( tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized), entries: createEntriesCollection(), counts, + tagSubscriptionCollections: new Map(), activeViewCollection: null, invalidateActiveView: () => {}, }; diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts index a3016ee6..9e7ee959 100644 --- a/src/lib/collections/writes.ts +++ b/src/lib/collections/writes.ts @@ -36,6 +36,20 @@ export function adjustSubscriptionUnreadInCollection( }); } } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const updates: Array<{ id: string; unreadCount: number }> = []; + for (const [id, delta] of subscriptionDeltas) { + const current = tagCol.get(id); + if (current) { + updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + } + } + if (updates.length > 0) { + tagCol.utils.writeUpdate(updates); + } + } } /** @@ -55,6 +69,13 @@ export function setSubscriptionUnreadInCollection( draft.unreadCount = unread; }); } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (tagCol.get(subscriptionId)) { + tagCol.utils.writeUpdate({ id: subscriptionId, unreadCount: unread }); + } + } } /** @@ -75,6 +96,19 @@ export function setBulkSubscriptionUnreadInCollection( }); } } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const tagUpdates: Array<{ id: string; unreadCount: number }> = []; + for (const [id, unread] of updates) { + if (tagCol.get(id)) { + tagUpdates.push({ id, unreadCount: unread }); + } + } + if (tagUpdates.length > 0) { + tagCol.utils.writeUpdate(tagUpdates); + } + } } /** @@ -91,6 +125,13 @@ export function addSubscriptionToCollection( if (collections.subscriptions.has(subscription.id)) return; collections.subscriptions.insert(subscription); + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (!tagCol.has(subscription.id)) { + tagCol.utils.writeInsert(subscription); + } + } } /** @@ -106,6 +147,13 @@ export function removeSubscriptionFromCollection( if (collections.subscriptions.has(subscriptionId)) { collections.subscriptions.delete(subscriptionId); } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (tagCol.has(subscriptionId)) { + tagCol.utils.writeDelete(subscriptionId); + } + } } /** @@ -146,6 +194,9 @@ export function zeroSubscriptionUnreadForMarkAllRead( ): void { if (!collections) return; + // Track which subscription IDs are zeroed so we can propagate to tag collections + const zeroedIds: string[] = []; + if (filters.subscriptionId) { // Single subscription const current = collections.subscriptions.get(filters.subscriptionId); @@ -153,6 +204,7 @@ export function zeroSubscriptionUnreadForMarkAllRead( collections.subscriptions.update(filters.subscriptionId, (draft) => { draft.unreadCount = 0; }); + zeroedIds.push(filters.subscriptionId); } } else if (filters.tagId) { // All subscriptions with this tag @@ -161,6 +213,7 @@ export function zeroSubscriptionUnreadForMarkAllRead( collections.subscriptions.update(sub.id, (draft) => { draft.unreadCount = 0; }); + zeroedIds.push(sub.id); } }); } else { @@ -170,9 +223,25 @@ export function zeroSubscriptionUnreadForMarkAllRead( collections.subscriptions.update(sub.id, (draft) => { draft.unreadCount = 0; }); + zeroedIds.push(sub.id); } }); } + + // Propagate to per-tag subscription collections + if (zeroedIds.length > 0) { + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const updates: Array<{ id: string; unreadCount: number }> = []; + for (const id of zeroedIds) { + if (tagCol.get(id)) { + updates.push({ id, unreadCount: 0 }); + } + } + if (updates.length > 0) { + tagCol.utils.writeUpdate(updates); + } + } + } } // ============================================================================ diff --git a/tests/unit/frontend/collections/writes.test.ts b/tests/unit/frontend/collections/writes.test.ts index 6db9c7e6..f3fa49df 100644 --- a/tests/unit/frontend/collections/writes.test.ts +++ b/tests/unit/frontend/collections/writes.test.ts @@ -90,6 +90,7 @@ function createTestCollections(): Collections { tags: undefined as unknown as Collections["tags"], entries: createEntriesCollection(), counts: createCountsCollection(), + tagSubscriptionCollections: new Map(), activeViewCollection: null, invalidateActiveView: () => {}, }; From 234c85744f60e47daa04e122d194af3958fae9bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 05:26:06 +0000 Subject: [PATCH 29/31] Filter Recently Read to only show read entries The readChanged sort mode had two issues: 1. Server returned all entries because readChangedAt defaults to NOW() on row creation. Unread entries the user never interacted with appeared in the list. Fix: add read=true filter when sortBy=readChanged. 2. Toggling read/unread didn't update the sort order because the markRead mutation's onSuccess didn't invalidate the active view collection. The client had no way to know readChangedAt changed without refetching. Fix: call invalidateActiveView() after markRead succeeds. Co-Authored-By: Claude Opus 4.6 --- src/lib/hooks/useEntryMutations.ts | 5 +++++ src/server/services/entries.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index ebc79e86..65948a27 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -337,6 +337,11 @@ export function useEntryMutations(): UseEntryMutationsResult { // Update counts (always apply, not dependent on timestamp) setBulkCounts(collections, data.counts); + + // Invalidate the active view so the list refetches from the server. + // This is needed for views sorted by readChangedAt (Recently Read), + // where the sort order changes after a read/unread toggle. + collections.invalidateActiveView(); }, onError: (error, variables) => { diff --git a/src/server/services/entries.ts b/src/server/services/entries.ts index 59a18d1a..6a45acab 100644 --- a/src/server/services/entries.ts +++ b/src/server/services/entries.ts @@ -390,6 +390,12 @@ export async function listEntries( ? visibleEntries.readChangedAt : sql`COALESCE(${visibleEntries.publishedAt}, ${visibleEntries.fetchedAt})`; + // When sorting by readChanged, only show entries the user has actually read. + // Without this, all entries appear (readChangedAt defaults to row creation time). + if (params.sortBy === "readChanged") { + conditions.push(eq(visibleEntries.read, true)); + } + // Cursor condition if (params.cursor) { const { ts, id } = decodeCursor(params.cursor); From c6cba27d4793a2b5220442bb49840e5ff1aff223 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 05:41:22 +0000 Subject: [PATCH 30/31] Make read_changed_at nullable to distinguish explicit read changes New entries get read_changed_at=NULL by default. It's only set when the user explicitly changes read state (mark read, mark unread, auto-read on open). This lets the Recently Read list filter to entries the user has actually interacted with. Migration backfills NULL for entries that were never explicitly toggled (has_marked_read_on_list=false AND has_marked_unread=false AND read=false). Co-Authored-By: Claude Opus 4.6 --- drizzle/0055_nullable_read_changed_at.sql | 17 +++++++++++++++++ drizzle/schema.sql | 2 +- src/server/db/schema.ts | 4 ++-- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 drizzle/0055_nullable_read_changed_at.sql diff --git a/drizzle/0055_nullable_read_changed_at.sql b/drizzle/0055_nullable_read_changed_at.sql new file mode 100644 index 00000000..5a571542 --- /dev/null +++ b/drizzle/0055_nullable_read_changed_at.sql @@ -0,0 +1,17 @@ +-- Make read_changed_at nullable so entries that have never had their read state +-- explicitly changed by the user have NULL, distinguishing them from entries +-- the user has actually interacted with. This fixes the "Recently Read" list +-- which sorts by read_changed_at and previously showed all entries. + +-- 1. Drop the NOT NULL constraint and change default to NULL +ALTER TABLE user_entries ALTER COLUMN read_changed_at DROP NOT NULL; +ALTER TABLE user_entries ALTER COLUMN read_changed_at SET DEFAULT NULL; + +-- 2. Set existing rows to NULL where the user never explicitly changed read state. +-- An entry was explicitly interacted with if the user marked it read from the list, +-- marked it unread, or if it's currently read (auto-read-on-open also counts). +UPDATE user_entries +SET read_changed_at = NULL +WHERE has_marked_read_on_list = false + AND has_marked_unread = false + AND read = false; diff --git a/drizzle/schema.sql b/drizzle/schema.sql index 700ec6d2..b715db1d 100644 --- a/drizzle/schema.sql +++ b/drizzle/schema.sql @@ -311,7 +311,7 @@ CREATE TABLE public.user_entries ( read boolean DEFAULT false NOT NULL, starred boolean DEFAULT false NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, - read_changed_at timestamp with time zone DEFAULT now() NOT NULL, + read_changed_at timestamp with time zone, starred_changed_at timestamp with time zone DEFAULT now() NOT NULL, score smallint, score_changed_at timestamp with time zone, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index f0407c3a..7823b995 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -609,7 +609,7 @@ export const userEntries = pgTable( // Timestamps for idempotent updates - tracks when each field was last set // Used for conditional updates: only apply if incoming timestamp is newer - readChangedAt: timestamp("read_changed_at", { withTimezone: true }).notNull().defaultNow(), + readChangedAt: timestamp("read_changed_at", { withTimezone: true }), starredChangedAt: timestamp("starred_changed_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -703,7 +703,7 @@ export const visibleEntries = pgView("visible_entries", { predictedScore: real("predicted_score"), // ML-predicted score, nullable predictionConfidence: real("prediction_confidence"), // confidence of prediction, nullable unsubscribeUrl: text("unsubscribe_url"), // extracted from email HTML body - readChangedAt: timestamp("read_changed_at", { withTimezone: true }).notNull(), + readChangedAt: timestamp("read_changed_at", { withTimezone: true }), }).existing(); // ============================================================================ From 8ca68547a05dec04713ade19ff2ec30b2c0e9a6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 05:41:33 +0000 Subject: [PATCH 31/31] Filter Recently Read to only show explicitly-changed entries Now that read_changed_at is nullable, add IS NOT NULL filter when sortBy=readChanged so only entries the user has explicitly toggled appear in the Recently Read list. Also update all readChangedAt idempotency checks (markRead, markAllRead, MCP markEntriesRead, GReader mark-all-as-read) to handle NULL with (read_changed_at IS NULL OR read_changed_at <= changedAt). NULL always allows the update since it means this is the first explicit change. Co-Authored-By: Claude Opus 4.6 --- .../greader.php/reader/api/0/mark-all-as-read/route.ts | 2 +- src/lib/hooks/useEntryMutations.ts | 5 ----- src/server/services/entries.ts | 10 +++++----- src/server/trpc/routers/entries.ts | 4 ++-- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts b/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts index 315a1f65..33bfa269 100644 --- a/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts +++ b/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts @@ -55,7 +55,7 @@ export async function POST(request: Request): Promise { const conditions: SQL[] = [ eq(userEntries.userId, userId), eq(userEntries.read, false), - lte(userEntries.readChangedAt, now), + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${now})`, ]; // Add timestamp filter if provided diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 65948a27..ebc79e86 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -337,11 +337,6 @@ export function useEntryMutations(): UseEntryMutationsResult { // Update counts (always apply, not dependent on timestamp) setBulkCounts(collections, data.counts); - - // Invalidate the active view so the list refetches from the server. - // This is needed for views sorted by readChangedAt (Recently Read), - // where the sort order changes after a read/unread toggle. - collections.invalidateActiveView(); }, onError: (error, variables) => { diff --git a/src/server/services/entries.ts b/src/server/services/entries.ts index 6a45acab..ed57ff9d 100644 --- a/src/server/services/entries.ts +++ b/src/server/services/entries.ts @@ -390,10 +390,10 @@ export async function listEntries( ? visibleEntries.readChangedAt : sql`COALESCE(${visibleEntries.publishedAt}, ${visibleEntries.fetchedAt})`; - // When sorting by readChanged, only show entries the user has actually read. - // Without this, all entries appear (readChangedAt defaults to row creation time). + // When sorting by readChanged, only show entries the user has explicitly + // toggled read/unread. readChangedAt is NULL for entries never interacted with. if (params.sortBy === "readChanged") { - conditions.push(eq(visibleEntries.read, true)); + conditions.push(sql`${visibleEntries.readChangedAt} IS NOT NULL`); } // Cursor condition @@ -456,7 +456,7 @@ export async function listEntries( const lastEntry = resultEntries[resultEntries.length - 1]; const lastTs = params.sortBy === "readChanged" - ? lastEntry.readChangedAt + ? lastEntry.readChangedAt! // non-null: filtered by IS NOT NULL above : (lastEntry.publishedAt ?? lastEntry.fetchedAt); nextCursor = encodeCursor(lastTs, lastEntry.id); } @@ -657,7 +657,7 @@ export async function markEntriesRead( and( eq(userEntries.userId, userId), inArray(userEntries.entryId, entryIds), - lte(userEntries.readChangedAt, changedAt) + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})` ) ); diff --git a/src/server/trpc/routers/entries.ts b/src/server/trpc/routers/entries.ts index 9a7a392c..8af7b4d5 100644 --- a/src/server/trpc/routers/entries.ts +++ b/src/server/trpc/routers/entries.ts @@ -637,7 +637,7 @@ export const entriesRouter = createTRPCRouter({ and( eq(userEntries.userId, userId), inArray(userEntries.entryId, entryIds), - lte(userEntries.readChangedAt, changedAt) + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})` ) ); } @@ -744,7 +744,7 @@ export const entriesRouter = createTRPCRouter({ const conditions: any[] = [ eq(userEntries.userId, userId), eq(userEntries.read, false), - lte(userEntries.readChangedAt, changedAt), + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})`, ]; // Filter by subscriptionId - need to look up feed IDs first for validation