From 07cbf35de77f1e06b21e4f73fb79f00ccb58d03e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 26 May 2026 11:55:09 +0800 Subject: [PATCH] fix: batch scroll mark-read requests --- .../hooks/useEntryMarkReadHandler.tsx | 4 +- .../src/modules/entry-column/index.tsx | 58 +++++--- apps/mobile/src/modules/entry-list/hooks.ts | 21 ++- .../shared/src/scroll-mark-read.test.ts | 21 +++ .../internal/shared/src/scroll-mark-read.ts | 29 ++++ .../store/src/modules/unread/store.test.ts | 132 ++++++++++++++++++ .../store/src/modules/unread/store.ts | 118 ++++++++++++++++ 7 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 packages/internal/store/src/modules/unread/store.test.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx index 3edfb7650..727578fd5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -84,8 +84,6 @@ export function batchMarkRead(ids: string[]) { } if (batchLikeIds.length > 0) { - for (const id of batchLikeIds) { - unreadSyncService.markEntryAsRead(id) - } + void unreadSyncService.queueEntriesAsRead(batchLikeIds) } } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index a1a726137..20cdd3ea9 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -1,5 +1,6 @@ import { FeedViewType, getView } from "@follow/constants" import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks" +import { getScrollMarkReadRange } from "@follow/shared/scroll-mark-read" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks" @@ -42,10 +43,12 @@ function EntryColumnContent() { const state = useEntriesState() const isInteracted = useRef(false) - const rangeQueueRef = useRef([]) + const scrollMarkReadEndIndexRef = useRef(null) + const latestRangeStartIndexRef = useRef(null) const resetScrollInteractionState = useCallback(() => { isInteracted.current = false - rangeQueueRef.current = [] + scrollMarkReadEndIndexRef.current = null + latestRangeStartIndexRef.current = null }, []) const actions = useEntriesActions() @@ -126,24 +129,37 @@ function EntryColumnContent() { pauseScrollMarkRead, }) + const flushScrollMarkRead = useCallback( + (currentStartIndex: number) => { + if (!routeFeedId) return + + const range = getScrollMarkReadRange({ + previousEndIndex: scrollMarkReadEndIndexRef.current, + currentStartIndex, + }) + + if (range) { + handleScrollMarkRead?.(range as Range, isInteracted.current) + scrollMarkReadEndIndexRef.current = currentStartIndex + return + } + + if (scrollMarkReadEndIndexRef.current === null) { + scrollMarkReadEndIndexRef.current = currentStartIndex + } + }, + [handleScrollMarkRead, routeFeedId], + ) + const handleScroll = useCallback(() => { if (!isInteracted.current) { isInteracted.current = true } - if (!routeFeedId) return - - const [first, second] = rangeQueueRef.current - if (first && second && second.startIndex - first.startIndex > 0) { - handleScrollMarkRead?.( - { - startIndex: first.startIndex, - endIndex: second.startIndex, - } as Range, - isInteracted.current, - ) + if (latestRangeStartIndexRef.current !== null) { + flushScrollMarkRead(latestRangeStartIndexRef.current) } - }, [handleScrollMarkRead, routeFeedId]) + }, [flushScrollMarkRead]) const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond() const handleCombinedScroll = useCallback( @@ -161,13 +177,15 @@ function EntryColumnContent() { const renderAsRead = useGeneralSettingKey("renderMarkUnread") const handleRangeChange = useCallback( (e: Range) => { - const [_, second] = rangeQueueRef.current - if (second?.startIndex === e.startIndex) { + if (latestRangeStartIndexRef.current === e.startIndex) { return } - rangeQueueRef.current.push(e) - if (rangeQueueRef.current.length > 2) { - rangeQueueRef.current.shift() + + latestRangeStartIndexRef.current = e.startIndex + if (scrollMarkReadEndIndexRef.current === null) { + scrollMarkReadEndIndexRef.current = e.startIndex + } else if (isInteracted.current) { + flushScrollMarkRead(e.startIndex) } if (!renderAsRead) return @@ -177,7 +195,7 @@ function EntryColumnContent() { // For gird, render as mark read logic handleRenderMarkRead?.(e, isInteracted.current) }, - [handleRenderMarkRead, renderAsRead, view], + [flushScrollMarkRead, handleRenderMarkRead, renderAsRead, view], ) const fetchNextPage = useCallback(() => { diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 89ab667e2..a6f5851a8 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -78,23 +78,20 @@ export function useOnViewableItemsChanged({ if (disabled) return if (isLoggedIn && markAsReadWhenScrolling && !pauseScrollMarkRead && lastRemovedItems) { - lastRemovedItems.forEach((item) => { - unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => { - setLastRemovedItems((prev) => { - if (prev) { - return prev.filter((prevItem) => prevItem.key !== item.key) - } else { - return null - } - }) + const entryIds = lastRemovedItems.map((item) => stableIdExtractor(item)) + const entryIdSet = new Set(entryIds) + void unreadSyncService.queueEntriesAsRead(entryIds).then(() => { + setLastRemovedItems((prev) => { + if (!prev) return null + return prev.filter((prevItem) => !entryIdSet.has(stableIdExtractor(prevItem))) }) }) } if (isLoggedIn && markAsReadWhenRendering && lastViewableItems) { - lastViewableItems.forEach((item) => { - unreadSyncService.markEntryAsRead(stableIdExtractor(item)) - }) + void unreadSyncService.queueEntriesAsRead( + lastViewableItems.map((item) => stableIdExtractor(item)), + ) } }, [ disabled, diff --git a/packages/internal/shared/src/scroll-mark-read.test.ts b/packages/internal/shared/src/scroll-mark-read.test.ts index 4e35573f3..a6304fec3 100644 --- a/packages/internal/shared/src/scroll-mark-read.test.ts +++ b/packages/internal/shared/src/scroll-mark-read.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import { getScrollMarkReadEndPadding, getScrollMarkReadExitedSliceEnd, + getScrollMarkReadRange, MIN_SCROLL_MARK_READ_END_PADDING, SCROLL_MARK_READ_END_INDICATOR_HEIGHT, shouldRenderScrollMarkReadEndSpacer, @@ -43,3 +44,23 @@ describe("scroll mark-read exited slice", () => { ).toBeNull() }) }) + +describe("scroll mark-read range", () => { + it("marks the full skipped range when scrolling jumps over intermediate entries", () => { + expect( + getScrollMarkReadRange({ + previousEndIndex: 4, + currentStartIndex: 12, + }), + ).toEqual({ startIndex: 4, endIndex: 12 }) + }) + + it("does not mark entries while scrolling upward or staying within the previous high-water mark", () => { + expect( + getScrollMarkReadRange({ + previousEndIndex: 12, + currentStartIndex: 8, + }), + ).toBeNull() + }) +}) diff --git a/packages/internal/shared/src/scroll-mark-read.ts b/packages/internal/shared/src/scroll-mark-read.ts index 2fb31dbd3..9b934209d 100644 --- a/packages/internal/shared/src/scroll-mark-read.ts +++ b/packages/internal/shared/src/scroll-mark-read.ts @@ -17,6 +17,35 @@ export const shouldRenderScrollMarkReadEndSpacer = ({ hasNextPage: boolean }) => entryCount > 0 && !hasNextPage +export const getScrollMarkReadRange = ({ + previousEndIndex, + currentStartIndex, +}: { + previousEndIndex: number | null | undefined + currentStartIndex: number | null | undefined +}) => { + if ( + typeof previousEndIndex !== "number" || + !Number.isInteger(previousEndIndex) || + previousEndIndex < 0 + ) { + return null + } + + if ( + typeof currentStartIndex !== "number" || + !Number.isInteger(currentStartIndex) || + currentStartIndex <= previousEndIndex + ) { + return null + } + + return { + startIndex: previousEndIndex, + endIndex: currentStartIndex, + } +} + export const getScrollMarkReadExitedSliceEnd = ({ indexes, renderedEndIndex, diff --git a/packages/internal/store/src/modules/unread/store.test.ts b/packages/internal/store/src/modules/unread/store.test.ts new file mode 100644 index 000000000..05212bb2a --- /dev/null +++ b/packages/internal/store/src/modules/unread/store.test.ts @@ -0,0 +1,132 @@ +import { FeedViewType } from "@follow/constants" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { apiContext } from "../../context" +import type { FollowAPI } from "../../types" +import { useEntryStore } from "../entry/store" +import type { EntryModel } from "../entry/types" +import { unreadSyncService, useUnreadStore } from "./store" + +const { entryPatchManyMock, unreadUpsertManyMock } = vi.hoisted(() => ({ + entryPatchManyMock: vi.fn(), + unreadUpsertManyMock: vi.fn(), +})) + +vi.mock("@follow/database/services/entry", () => ({ + EntryService: { + patchMany: entryPatchManyMock, + }, +})) + +vi.mock("@follow/database/services/unread", () => ({ + UnreadService: { + getUnreadAll: vi.fn(), + reset: vi.fn(), + upsertMany: unreadUpsertManyMock, + }, +})) + +const createEntry = (id: string, feedId: string, read = false): EntryModel => ({ + id, + guid: `${id}-guid`, + insertedAt: new Date("2026-01-01T00:00:00.000Z"), + publishedAt: new Date("2026-01-01T00:00:00.000Z"), + feedId, + read, +}) + +describe("unreadSyncService", () => { + const markAsReadMock = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + useEntryStore.setState({ + data: {}, + entryIdByView: { + [FeedViewType.All]: new Set(), + [FeedViewType.Articles]: new Set(), + [FeedViewType.Audios]: new Set(), + [FeedViewType.Notifications]: new Set(), + [FeedViewType.Pictures]: new Set(), + [FeedViewType.SocialMedia]: new Set(), + [FeedViewType.Videos]: new Set(), + }, + entryIdByCategory: {}, + entryIdByFeed: {}, + entryIdByInbox: {}, + entryIdByList: {}, + entryIdSet: new Set(), + }) + useUnreadStore.setState({ data: {} }) + apiContext.provide({ + reads: { + markAsRead: markAsReadMock, + }, + } as unknown as FollowAPI) + }) + + it("marks multiple feed entries as read with one request and one local patch", async () => { + const entries = { + entry1: createEntry("entry1", "feed1"), + entry2: createEntry("entry2", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 2 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + await unreadSyncService.markEntriesAsRead(["entry1", "entry2"]) + + expect(markAsReadMock).toHaveBeenCalledTimes(1) + expect(markAsReadMock).toHaveBeenCalledWith({ + entryIds: ["entry1", "entry2"], + isInbox: false, + }) + expect(entryPatchManyMock).toHaveBeenCalledTimes(1) + expect(entryPatchManyMock).toHaveBeenCalledWith({ + entry: { read: true }, + entryIds: ["entry1", "entry2"], + }) + expect(useEntryStore.getState().data.entry1?.read).toBe(true) + expect(useEntryStore.getState().data.entry2?.read).toBe(true) + expect(useUnreadStore.getState().data.feed1).toBe(0) + }) + + it("queues rapid read marks into one batched request", async () => { + vi.useFakeTimers() + + try { + const entries = { + entry1: createEntry("entry1", "feed1"), + entry2: createEntry("entry2", "feed1"), + } + useEntryStore.setState((state) => ({ + ...state, + data: entries, + entryIdSet: new Set(Object.keys(entries)), + })) + useUnreadStore.setState({ data: { feed1: 2 } }) + markAsReadMock.mockResolvedValue({ data: null }) + + const firstFlush = unreadSyncService.queueEntriesAsRead(["entry1"]) + const secondFlush = unreadSyncService.queueEntriesAsRead(["entry2"]) + + expect(markAsReadMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + await Promise.all([firstFlush, secondFlush]) + + expect(markAsReadMock).toHaveBeenCalledTimes(1) + expect(markAsReadMock).toHaveBeenCalledWith({ + entryIds: ["entry1", "entry2"], + isInbox: false, + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/internal/store/src/modules/unread/store.ts b/packages/internal/store/src/modules/unread/store.ts index e7e7bcbd3..2d4bed1a2 100644 --- a/packages/internal/store/src/modules/unread/store.ts +++ b/packages/internal/store/src/modules/unread/store.ts @@ -26,11 +26,22 @@ const initialUnreadStore: UnreadState = { data: {}, } +const READ_MARK_BATCH_WINDOW = 100 + export const useUnreadStore = createZustandStore("unread")(() => initialUnreadStore) const get = useUnreadStore.getState const set = useUnreadStore.setState +type ReadEntryTarget = { + entryId: string + id: FeedIdOrInboxHandle + isInbox: boolean +} + class UnreadSyncService { + private queuedReadEntryIds = new Set() + private queuedReadFlushPromise: Promise | null = null + async resetFromRemote() { const res = await api().reads.get({}) @@ -192,7 +203,114 @@ class UnreadSyncService { }) } + private getReadEntryTargets(entryIds: string[]): ReadEntryTarget[] { + const seenEntryIds = new Set() + const targets: ReadEntryTarget[] = [] + + for (const entryId of entryIds) { + if (seenEntryIds.has(entryId)) continue + seenEntryIds.add(entryId) + + const entry = getEntry(entryId) + if (!entry || entry.read || (!entry.feedId && !entry.inboxHandle)) continue + + targets.push({ + entryId, + id: entry.inboxHandle || entry.feedId || "", + isInbox: !!entry.inboxHandle, + }) + } + + return targets + } + + async markEntriesAsRead(entryIds: string[]) { + const targets = this.getReadEntryTargets(entryIds) + if (targets.length === 0) return + + const targetEntryIds = targets.map((target) => target.entryId) + const unreadCountById = targets.reduce( + (acc, target) => { + acc[target.id] = (acc[target.id] || 0) + 1 + return acc + }, + {} as Record, + ) + + const feedEntryIds = targets.filter((target) => !target.isInbox).map((target) => target.entryId) + const inboxEntryIds = targets.filter((target) => target.isInbox).map((target) => target.entryId) + + const tx = createTransaction() + tx.store(() => { + entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: true }) + for (const [id, count] of Object.entries(unreadCountById)) { + unreadActions.removeUnread(id, count) + } + }) + + tx.request(async () => { + if (feedEntryIds.length > 0) { + await api().reads.markAsRead({ entryIds: feedEntryIds, isInbox: false }) + } + if (inboxEntryIds.length > 0) { + await api().reads.markAsRead({ entryIds: inboxEntryIds, isInbox: true }) + } + }) + + tx.rollback(() => { + entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: false }) + for (const [id, count] of Object.entries(unreadCountById)) { + unreadActions.addUnread(id, count) + } + }) + + tx.persist(() => { + return EntryService.patchMany({ + entry: { read: true }, + entryIds: targetEntryIds, + }) + }) + + Object.keys(unreadCountById).forEach((id) => { + if (id) { + setFeedUnreadDirty(id) + } + }) + + await tx.run() + } + + queueEntriesAsRead(entryIds: string[]) { + for (const entryId of entryIds) { + this.queuedReadEntryIds.add(entryId) + } + + if (this.queuedReadFlushPromise) { + return this.queuedReadFlushPromise + } + + this.queuedReadFlushPromise = new Promise((resolve) => { + setTimeout(() => { + const queuedEntryIds = Array.from(this.queuedReadEntryIds) + this.queuedReadEntryIds.clear() + this.queuedReadFlushPromise = null + + this.markEntriesAsRead(queuedEntryIds) + .catch((error) => { + console.error(error) + }) + .finally(resolve) + }, READ_MARK_BATCH_WINDOW) + }) + + return this.queuedReadFlushPromise + } + private async markEntryReadStatus({ entryId, read }: { entryId: string; read: boolean }) { + if (read) { + return this.markEntriesAsRead([entryId]) + } + const entry = getEntry(entryId) if (!entry || entry.read === read || (!entry.feedId && !entry.inboxHandle)) return