fix: batch scroll mark-read requests

This commit is contained in:
DIYgod 2026-05-26 11:55:09 +08:00
parent 27b30e0d47
commit 07cbf35de7
7 changed files with 348 additions and 35 deletions

View File

@ -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)
}
}

View File

@ -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<Range[]>([])
const scrollMarkReadEndIndexRef = useRef<number | null>(null)
const latestRangeStartIndexRef = useRef<number | null>(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(() => {

View File

@ -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,

View File

@ -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()
})
})

View File

@ -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,

View File

@ -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()
}
})
})

View File

@ -26,11 +26,22 @@ const initialUnreadStore: UnreadState = {
data: {},
}
const READ_MARK_BATCH_WINDOW = 100
export const useUnreadStore = createZustandStore<UnreadState>("unread")(() => initialUnreadStore)
const get = useUnreadStore.getState
const set = useUnreadStore.setState
type ReadEntryTarget = {
entryId: string
id: FeedIdOrInboxHandle
isInbox: boolean
}
class UnreadSyncService {
private queuedReadEntryIds = new Set<string>()
private queuedReadFlushPromise: Promise<void> | 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<string>()
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<FeedIdOrInboxHandle, number>,
)
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<void>((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