fix(mobile): keep marking entries read after the first one scrolls past

Marking entries read on scroll stopped after the first entry. Once the
scrolled-out entries were handed off, the list kept for them was stored
again as a new array, an empty one when all of them were handed off.
That ran the marking effect again, and since marking goes through the
transaction queue, marking nothing resolves at once, so the effect ran
every few milliseconds without end. It kept the JS thread busy: no
later entry was reported as scrolled out, and the queue's flush timer
never fired, so even the first entry only reached the server on the
next launch.

Store the same list when nothing changed, and none when nothing is
left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-10-13 21:30:31 +08:00
co-authored by Claude Opus 5.5
parent 306460b755
commit 4e489f4e5e
3 changed files with 54 additions and 6 deletions
+5 -5
View File
@@ -8,7 +8,10 @@ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native"
import { useGeneralSettingKey } from "@/src/atoms/settings/general"
import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read"
import {
getPendingMarkReadItems,
shouldCollectViewableItemsForMarkRead,
} from "./viewable-mark-read"
const defaultIdExtractor = (item: ViewToken<string>) => item.key
export function useOnViewableItemsChanged({
@@ -95,10 +98,7 @@ export function useOnViewableItemsChanged({
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)))
})
setLastRemovedItems((prev) => getPendingMarkReadItems(prev, entryIdSet, stableIdExtractor))
})
}
@@ -1,6 +1,9 @@
import { describe, expect, test } from "vitest"
import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read"
import {
getPendingMarkReadItems,
shouldCollectViewableItemsForMarkRead,
} from "./viewable-mark-read"
describe("shouldCollectViewableItemsForMarkRead", () => {
test("does not collect viewable items while disabled", () => {
@@ -37,3 +40,30 @@ describe("shouldCollectViewableItemsForMarkRead", () => {
).toBe(false)
})
})
describe("getPendingMarkReadItems", () => {
const getId = (item: { key: string }) => item.key
test("keeps the items that were not handed off yet", () => {
const pending = { key: "b" }
expect(
getPendingMarkReadItems([{ key: "a" }, pending, { key: "c" }], new Set(["a", "c"]), getId),
).toEqual([pending])
})
// The hook marks the stored items whenever they change, and marking entries that are already
// read resolves at once, so a new array for an unchanged or empty list would mark forever
test("is null once every handed-off item was marked", () => {
expect(getPendingMarkReadItems([{ key: "a" }], new Set(["a"]), getId)).toBeNull()
expect(getPendingMarkReadItems(null, new Set(["a"]), getId)).toBeNull()
})
test("returns the same list when none of its items was handed off", () => {
const items = [{ key: "a" }]
const empty: { key: string }[] = []
expect(getPendingMarkReadItems(items, new Set(["b"]), getId)).toBe(items)
expect(getPendingMarkReadItems(empty, new Set(["a"]), getId)).toBe(empty)
})
})
@@ -7,3 +7,21 @@ export const shouldCollectViewableItemsForMarkRead = ({
isScrollingDown: boolean
offset: number
}) => !disabled && isScrollingDown && offset > 0
/**
* The scrolled-out items still waiting to be marked read once `markedIds` were handed off.
* The result is stored in state that triggers the marking, so it keeps the same array when
* nothing changed and is null when nothing is left. Any new array would mark again, which
* resolves at once for entries that are already read, and so would loop without end.
*/
export const getPendingMarkReadItems = <T>(
items: T[] | null,
markedIds: ReadonlySet<string>,
getId: (item: T) => string,
): T[] | null => {
if (!items) return null
const pending = items.filter((item) => !markedIds.has(getId(item)))
if (pending.length === items.length) return items
return pending.length > 0 ? pending : null
}