Skip to content

Commit 2ed4ac5

Browse files
committed
fix(timeline): guard mark read during scroll reset
1 parent fe1fcff commit 2ed4ac5

17 files changed

Lines changed: 543 additions & 56 deletions

apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-masonry.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { imageActions } from "~/store/image"
4343
import { useEntriesState } from "../context/EntriesContext"
4444
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
4545
import { useScrollMarkReadEndPadding } from "../hooks/useScrollMarkReadEndPadding"
46+
import { shouldApplyScrollResetSignal } from "../scroll-reset"
4647
import { PictureWaterFallItem } from "./picture-item"
4748

4849
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
@@ -53,7 +54,7 @@ const FirstScreenReadyContext = createContext(false)
5354
const gutter = 24
5455

5556
export const PictureMasonry: FC<MasonryProps> = (props) => {
56-
const { data } = props
57+
const { appliedResetScrollSignal, data, onResetScrollSignalConsumed, resetScrollSignal } = props
5758
const entriesState = useEntriesState()
5859
const pauseScrollMarkRead = useScrollMarkReadGracePeriod(
5960
entriesState.isFetching && !entriesState.isFetchingNextPage,
@@ -146,6 +147,27 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
146147
hasNextPage: props.hasNextPage,
147148
})
148149
const endSpacerHeight = useScrollMarkReadEndPadding(scrollElement, hasEndSpacer)
150+
const isResetScrollPending = shouldApplyScrollResetSignal({
151+
resetSignal: resetScrollSignal,
152+
appliedResetSignal: appliedResetScrollSignal,
153+
})
154+
useLayoutEffect(() => {
155+
if (!scrollElement) return
156+
if (!isInitDim || !deferIsInitLayout) return
157+
if (!isResetScrollPending) return
158+
if (resetScrollSignal === undefined) return
159+
160+
scrollElement.scrollTop = 0
161+
scrollElement.scrollLeft = 0
162+
onResetScrollSignalConsumed?.(resetScrollSignal)
163+
}, [
164+
onResetScrollSignalConsumed,
165+
deferIsInitLayout,
166+
isInitDim,
167+
isResetScrollPending,
168+
resetScrollSignal,
169+
scrollElement,
170+
])
149171
const handleRender = useCallback(
150172
(startIndex: number, stopIndex: number, items: any[]) => {
151173
currentRange.current = { start: startIndex, end: stopIndex }
@@ -161,6 +183,7 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
161183
const dataRef = useRefValue(data)
162184
useEffect(() => {
163185
if (!renderMarkRead && !scrollMarkRead) return
186+
if (props.suspendMarkRead) return
164187
if (!scrollElement) return
165188

166189
const observer = new IntersectionObserver(
@@ -224,7 +247,14 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
224247
return () => {
225248
observer.disconnect()
226249
}
227-
}, [dataRef, pauseScrollMarkRead, renderMarkRead, scrollElement, scrollMarkRead])
250+
}, [
251+
dataRef,
252+
pauseScrollMarkRead,
253+
props.suspendMarkRead,
254+
renderMarkRead,
255+
scrollElement,
256+
scrollMarkRead,
257+
])
228258

229259
const [firstScreenReady, setFirstScreenReady] = useState(false)
230260
useEffect(() => {
@@ -328,6 +358,10 @@ interface MasonryProps {
328358
endReached: () => any
329359
hasNextPage: boolean
330360
Footer?: FC | ReactNode
361+
appliedResetScrollSignal?: number
362+
onResetScrollSignalConsumed?: (signal: number) => void
363+
resetScrollSignal?: number
364+
suspendMarkRead?: boolean
331365
}
332366

333367
const LoadingSkeletonItem = () => {

apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding
2525
import { EntryItem } from "./item"
2626
import { PictureMasonry } from "./Items/picture-masonry"
2727
import type { EntryListProps } from "./list"
28+
import { getInitialScrollOffset, shouldApplyScrollResetSignal } from "./scroll-reset"
2829

2930
export const EntryColumnGrid: FC<EntryListProps> = (props) => {
3031
const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props
@@ -40,6 +41,10 @@ export const EntryColumnGrid: FC<EntryListProps> = (props) => {
4041
endReached={fetchNextPage}
4142
data={entriesIds}
4243
Footer={props.Footer}
44+
appliedResetScrollSignal={props.appliedResetScrollSignal}
45+
onResetScrollSignalConsumed={props.onResetScrollSignalConsumed}
46+
resetScrollSignal={props.resetScrollSignal}
47+
suspendMarkRead={props.suspendMarkRead}
4348
/>
4449
)
4550
}
@@ -102,6 +107,9 @@ const VirtualGridImpl: FC<
102107
listRef,
103108
measureRef,
104109
containerWidth,
110+
appliedResetScrollSignal,
111+
onResetScrollSignalConsumed,
112+
resetScrollSignal,
105113
} = props
106114
const scrollRef = useScrollViewElement()
107115

@@ -136,6 +144,10 @@ const VirtualGridImpl: FC<
136144

137145
const rowCacheKey = `${feedId}-row`
138146
const columnCacheKey = `${feedId}-column`
147+
const isResetScrollPending = shouldApplyScrollResetSignal({
148+
resetSignal: resetScrollSignal,
149+
appliedResetSignal: appliedResetScrollSignal,
150+
})
139151
const footerRowIndex = rows.length + (hasNextPage ? 1 : 0)
140152
const rowCount = footerRowIndex + (Footer ? 1 : 0)
141153
const estimatedRowHeight = columns[0]! / (ratioMap[view] ?? 1) + (!isImageOnly ? 58 : 0)
@@ -146,7 +158,11 @@ const VirtualGridImpl: FC<
146158
getScrollElement: () => scrollRef,
147159
estimateSize: (i) => columns[i]!,
148160
overscan: 5,
149-
initialOffset: offsetCache.get(columnCacheKey) ?? 0,
161+
initialOffset: getInitialScrollOffset({
162+
cachedOffset: offsetCache.get(columnCacheKey),
163+
resetSignal: resetScrollSignal,
164+
appliedResetSignal: appliedResetScrollSignal,
165+
}),
150166
initialMeasurementsCache: measurementsCache.get(columnCacheKey) ?? [],
151167
onChange: useTypeScriptHappyCallback(
152168
(virtualizer: Virtualizer<HTMLElement, Element>) => {
@@ -165,7 +181,11 @@ const VirtualGridImpl: FC<
165181
overscan: 5,
166182
gap: 8,
167183
getScrollElement: () => scrollRef,
168-
initialOffset: offsetCache.get(rowCacheKey) ?? 0,
184+
initialOffset: getInitialScrollOffset({
185+
cachedOffset: offsetCache.get(rowCacheKey),
186+
resetSignal: resetScrollSignal,
187+
appliedResetSignal: appliedResetScrollSignal,
188+
}),
169189
initialMeasurementsCache: measurementsCache.get(rowCacheKey) ?? [],
170190
paddingEnd: 32,
171191
onChange: useTypeScriptHappyCallback(
@@ -194,6 +214,29 @@ const VirtualGridImpl: FC<
194214
listRef.current = rowVirtualizer
195215
}, [rowVirtualizer, listRef])
196216

217+
useLayoutEffect(() => {
218+
if (!scrollRef) return
219+
if (!isResetScrollPending) return
220+
if (resetScrollSignal === undefined) return
221+
222+
rowVirtualizer.scrollToOffset(0)
223+
columnVirtualizer.scrollToOffset(0)
224+
scrollRef.scrollTop = 0
225+
scrollRef.scrollLeft = 0
226+
offsetCache.put(rowCacheKey, 0)
227+
offsetCache.put(columnCacheKey, 0)
228+
onResetScrollSignalConsumed?.(resetScrollSignal)
229+
}, [
230+
columnCacheKey,
231+
columnVirtualizer,
232+
isResetScrollPending,
233+
onResetScrollSignalConsumed,
234+
resetScrollSignal,
235+
rowCacheKey,
236+
rowVirtualizer,
237+
scrollRef,
238+
])
239+
197240
useLayoutEffect(() => {
198241
measureRef.current = () => {
199242
rowVirtualizer.measure()

apps/desktop/layer/renderer/src/modules/entry-column/index.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { useIsLoggedIn } from "@follow/store/user/hooks"
99
import { isBizId } from "@follow/utils/utils"
1010
import type { Range, Virtualizer } from "@tanstack/react-virtual"
1111
import { atom, useAtomValue } from "jotai"
12-
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"
12+
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
1313
import { useTranslation } from "react-i18next"
1414

1515
import { useGeneralSettingKey } from "~/atoms/settings/general"
@@ -35,6 +35,7 @@ import { useNavigateFirstEntry } from "./hooks/useNavigateFirstEntry"
3535
import { EntryListHeader } from "./layouts/EntryListHeader"
3636
import { EntryEmptyList, EntryList } from "./list"
3737
import { shouldScrollTimelineToTopOnRefreshStateChange } from "./refresh-reset"
38+
import { shouldSuspendMarkReadForScrollReset } from "./scroll-reset"
3839
import { EntryRootStateContext } from "./store/EntryColumnContext"
3940

4041
function EntryColumnContent() {
@@ -53,8 +54,20 @@ function EntryColumnContent() {
5354
}, [])
5455

5556
const actions = useEntriesActions()
57+
const [resetScrollSignal, setResetScrollSignal] = useState<number>()
58+
const [appliedResetScrollSignal, setAppliedResetScrollSignal] = useState<number>()
59+
const isScrollResetPending = shouldSuspendMarkReadForScrollReset({
60+
resetSignal: resetScrollSignal,
61+
appliedResetSignal: appliedResetScrollSignal,
62+
})
63+
const handleResetScrollSignalConsumed = useCallback((signal: number) => {
64+
setAppliedResetScrollSignal((currentSignal) =>
65+
currentSignal === signal ? currentSignal : signal,
66+
)
67+
}, [])
5668
const scrollTimelineToTop = useCallback(() => {
5769
resetScrollInteractionState()
70+
setResetScrollSignal((signal) => (signal ?? 0) + 1)
5871

5972
const runScrollToTop = () => {
6073
listRef.current?.scrollToOffset(0)
@@ -155,14 +168,18 @@ function EntryColumnContent() {
155168
)
156169

157170
const handleScroll = useCallback(() => {
171+
if (isScrollResetPending) {
172+
return
173+
}
174+
158175
if (!isInteracted.current) {
159176
isInteracted.current = true
160177
}
161178

162179
if (latestRangeStartIndexRef.current !== null) {
163180
flushScrollMarkRead(latestRangeStartIndexRef.current)
164181
}
165-
}, [flushScrollMarkRead])
182+
}, [flushScrollMarkRead, isScrollResetPending])
166183

167184
const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond()
168185
const handleCombinedScroll = useCallback(
@@ -185,6 +202,10 @@ function EntryColumnContent() {
185202
}
186203

187204
latestRangeStartIndexRef.current = e.startIndex
205+
if (isScrollResetPending) {
206+
return
207+
}
208+
188209
if (scrollMarkReadAnchorIndexRef.current === null) {
189210
scrollMarkReadAnchorIndexRef.current = e.startIndex
190211
} else if (isInteracted.current) {
@@ -198,7 +219,7 @@ function EntryColumnContent() {
198219
// For gird, render as mark read logic
199220
handleRenderMarkRead?.(e, isInteracted.current)
200221
},
201-
[flushScrollMarkRead, handleRenderMarkRead, renderAsRead, view],
222+
[flushScrollMarkRead, handleRenderMarkRead, isScrollResetPending, renderAsRead, view],
202223
)
203224

204225
const fetchNextPage = useCallback(() => {
@@ -257,6 +278,10 @@ function EntryColumnContent() {
257278
fetchNextPage={fetchNextPage}
258279
refetch={actions.refetch}
259280
groupCounts={groupedCounts}
281+
appliedResetScrollSignal={appliedResetScrollSignal}
282+
onResetScrollSignalConsumed={handleResetScrollSignalConsumed}
283+
resetScrollSignal={resetScrollSignal}
284+
suspendMarkRead={isScrollResetPending}
260285
syncType={state.type}
261286
Footer={
262287
isCollection ? void 0 : <FooterMarkItem view={view} fetchedTime={state.fetchedTime} />

apps/desktop/layer/renderer/src/modules/entry-column/list.tsx

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
88
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
99
import type { HTMLMotionProps } from "motion/react"
1010
import type { FC, MutableRefObject, ReactNode } from "react"
11-
import { memo, startTransition, useEffect, useMemo, useRef, useState } from "react"
11+
import { memo, startTransition, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
1212
import { useTranslation } from "react-i18next"
1313
import { useEventCallback } from "usehooks-ts"
1414

@@ -20,6 +20,7 @@ import { VirtualRowItem } from "./components/VirtualRowItem"
2020
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
2121
import { EntryItemSkeleton } from "./EntryItemSkeleton"
2222
import { useScrollMarkReadEndPadding } from "./hooks/useScrollMarkReadEndPadding"
23+
import { getInitialScrollOffset, shouldApplyScrollResetSignal } from "./scroll-reset"
2324

2425
export const EntryEmptyList = ({
2526
ref,
@@ -66,6 +67,10 @@ export type EntryListProps = {
6667
onRangeChange?: (range: Range) => void
6768

6869
listRef?: MutableRefObject<Virtualizer<HTMLElement, Element> | undefined>
70+
appliedResetScrollSignal?: number
71+
onResetScrollSignalConsumed?: (signal: number) => void
72+
resetScrollSignal?: number
73+
suspendMarkRead?: boolean
6974
}
7075

7176
const capacity = 3
@@ -91,6 +96,9 @@ export const EntryList: FC<EntryListProps> = memo(
9196
onRangeChange,
9297
gap,
9398
syncType,
99+
appliedResetScrollSignal,
100+
onResetScrollSignalConsumed,
101+
resetScrollSignal,
94102
}) => {
95103
const scrollRef = useScrollViewElement()
96104
const hasEndSpacer = shouldRenderScrollMarkReadEndSpacer({
@@ -114,13 +122,21 @@ export const EntryList: FC<EntryListProps> = memo(
114122
)
115123

116124
const cacheKey = `${view}-${feedId}`
125+
const isResetScrollPending = shouldApplyScrollResetSignal({
126+
resetSignal: resetScrollSignal,
127+
appliedResetSignal: appliedResetScrollSignal,
128+
})
117129
const rowVirtualizer = useVirtualizer({
118130
count: entriesIds.length + 1,
119131
estimateSize: () => 112,
120132
overscan: 5,
121133
gap,
122134
getScrollElement: () => scrollRef,
123-
initialOffset: offsetCache.get(cacheKey) ?? 0,
135+
initialOffset: getInitialScrollOffset({
136+
cachedOffset: offsetCache.get(cacheKey),
137+
resetSignal: resetScrollSignal,
138+
appliedResetSignal: appliedResetScrollSignal,
139+
}),
124140
initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [],
125141
onChange: useTypeScriptHappyCallback(
126142
(virtualizer: Virtualizer<HTMLElement, Element>) => {
@@ -151,6 +167,25 @@ export const EntryList: FC<EntryListProps> = memo(
151167
listRef.current = rowVirtualizer
152168
}, [rowVirtualizer, listRef])
153169

170+
useLayoutEffect(() => {
171+
if (!scrollRef) return
172+
if (!isResetScrollPending) return
173+
if (resetScrollSignal === undefined) return
174+
175+
rowVirtualizer.scrollToOffset(0)
176+
scrollRef.scrollTop = 0
177+
scrollRef.scrollLeft = 0
178+
offsetCache.put(cacheKey, 0)
179+
onResetScrollSignalConsumed?.(resetScrollSignal)
180+
}, [
181+
cacheKey,
182+
isResetScrollPending,
183+
onResetScrollSignalConsumed,
184+
resetScrollSignal,
185+
rowVirtualizer,
186+
scrollRef,
187+
])
188+
154189
const handleScrollTo = useEventCallback((index: number) => {
155190
rowVirtualizer.scrollToIndex(index)
156191
})

0 commit comments

Comments
 (0)