Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
32b8928
Add TanStack DB collections (Phase 0 - no behavioral changes)
claude Feb 8, 2026
5d46766
Migrate sidebar to TanStack DB live queries (Phase 1)
claude Feb 8, 2026
d69606c
Add entries collection with dual-write for mutations and SSE (Phase 2a)
claude Feb 9, 2026
9a14af5
Migrate fallback components to TanStack DB collections (Phase 2b)
claude Feb 9, 2026
9839383
Remove React Query entry list cache updates, use TanStack DB (Phase 3)
claude Feb 9, 2026
d3d83a1
Migrate sidebar counts to TanStack DB, remove React Query dual-writes…
claude Feb 9, 2026
121e1c0
Remove React Query count invalidations, delete entry-cache.ts (Phase 5)
claude Feb 9, 2026
08d3b57
Implement Phase 6: on-demand collections with collection-driven rende…
claude Feb 9, 2026
5367716
Merge pull request #562 from brendanlong/claude/0ec15360-d254-4fac-aa…
brendanlong Feb 9, 2026
375929d
Update docs for TanStack DB collections migration (Phases 4-6)
claude Feb 9, 2026
5c7dc06
Merge pull request #567 from brendanlong/claude/d3629f87-b3a7-4a39-ae…
brendanlong Feb 9, 2026
470877e
Simplify Suspense boundaries after TanStack DB migration
claude Feb 9, 2026
f44cac7
Merge pull request #568 from brendanlong/claude/123f645c-131d-45ae-81…
brendanlong Feb 9, 2026
4f3d494
Fix title not loading on direct page load for subscription/tag pages
claude Feb 9, 2026
ab72226
Merge pull request #569 from brendanlong/fix/title-reactivity
brendanlong Feb 9, 2026
e703f21
Switch PWA share target back to auto-closing save page (#566)
claude Feb 9, 2026
9c863f2
Merge pull request #570 from brendanlong/claude/b5a1f436-10e5-4faa-ae…
brendanlong Feb 9, 2026
e92cb8a
Remove redundant collection updates in markRead/setStarred onSuccess …
claude Feb 9, 2026
e8d50dd
Remove unnecessary useMemo mapping in SuspendingEntryList (#577)
claude Feb 9, 2026
f5502e2
Replace manual SSE event parsing with Zod schemas (#579)
claude Feb 9, 2026
db89836
Move uncategorized count sync out of tags select into query cache sub…
claude Feb 9, 2026
8fe9ef8
Remove redundant fetch probe from SSE connection setup (#574)
claude Feb 9, 2026
3b66b2b
Update entry_state_changed SSE events to include count delta data (#572)
claude Feb 9, 2026
3e0626b
Add totalCount to subscription model for correct entry count deltas (…
claude Feb 9, 2026
161d661
Fix entry lists not updating on navigation or new entries (#571)
claude Feb 9, 2026
5300f68
Fix useRef type error by providing explicit initial value
claude Feb 9, 2026
c1261cf
Merge master into tanstack-db to resolve conflicts
claude Feb 21, 2026
88f75a1
Merge remote-tracking branch 'origin/master' into tanstack-db
brendanlong Feb 21, 2026
8cbcc6b
Fix counts collection seeding race condition (#622)
claude Feb 21, 2026
1c6c488
Fix SSE count drift and missing feedType handling (#623)
claude Feb 21, 2026
d577b1a
Fix markAllRead not zeroing subscription unread counts in collection …
claude Feb 21, 2026
d875f9c
Clean up dead code and type safety issues (#625)
claude Feb 21, 2026
3cbf5a0
Add unit tests for TanStack DB collection writes and count syncing
claude Feb 21, 2026
b6fe20a
Make feedType required on new_entry events (#623)
claude Feb 21, 2026
87b934f
Fix sidebar subscription unread counts not updating in tag sections
claude Feb 21, 2026
234c857
Filter Recently Read to only show read entries
claude Feb 21, 2026
c6cba27
Make read_changed_at nullable to distinguish explicit read changes
claude Feb 21, 2026
8ca6854
Filter Recently Read to only show explicitly-changed entries
claude Feb 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 62 additions & 4 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
153 changes: 98 additions & 55 deletions docs/diagrams/frontend-data-flow.d2
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
|
}
}
Expand All @@ -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"
}
}

Expand All @@ -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: {
Expand All @@ -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"
}

Expand All @@ -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
# ==============================================================================
Expand All @@ -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"
}
Loading