From 4e489f4e5edb4a5cfaf2b03d93dd152ddd007bfe Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 13 Oct 2026 21:30:31 +0800 Subject: [PATCH] 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 --- apps/mobile/src/modules/entry-list/hooks.ts | 10 +++--- .../entry-list/viewable-mark-read.test.ts | 32 ++++++++++++++++++- .../modules/entry-list/viewable-mark-read.ts | 18 +++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 06188176c..4437e4015 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -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) => 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)) }) } diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts index cf6f40600..0865dc73c 100644 --- a/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts @@ -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) + }) +}) diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts index 7be7642ac..75cb98784 100644 --- a/apps/mobile/src/modules/entry-list/viewable-mark-read.ts +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts @@ -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 = ( + items: T[] | null, + markedIds: ReadonlySet, + 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 +}