fix(desktop): scroll timeline to top before refresh

This commit is contained in:
DIYgod 2026-05-21 14:31:56 +08:00
parent 431ab0e0be
commit 8557b9f158
4 changed files with 141 additions and 9 deletions

View File

@ -0,0 +1,111 @@
import * as React from "react"
import { act } from "react"
import type { Root } from "react-dom/client"
import { createRoot } from "react-dom/client"
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"
import type { useEntriesByView as useEntriesByViewType } from "../hooks/useEntriesByView"
import { EntriesProvider, useEntriesActions } from "./EntriesContext"
const mocks = vi.hoisted(() => ({
useEntriesByView: vi.fn(),
routeParams: vi.fn(() => ({ view: 0 })),
}))
vi.mock("../hooks/useEntriesByView", () => ({
useEntriesByView: mocks.useEntriesByView,
}))
vi.mock("~/hooks/biz/useRouteParams", () => ({
useRouteParams: mocks.routeParams,
}))
const useEntriesByViewMock = mocks.useEntriesByView as unknown as {
mockReturnValue: (value: ReturnType<typeof useEntriesByViewType>) => void
}
describe("EntriesProvider", () => {
let root: Root | null = null
let container: HTMLElement | null = null
beforeAll(() => {
;(globalThis as typeof globalThis & { React: typeof React }).React = React
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
container?.remove()
root = null
container = null
vi.restoreAllMocks()
})
test("runs the registered reset callback before refetching entries", async () => {
const events: string[] = []
const refetch = vi.fn(async () => {
events.push("refetch")
})
useEntriesByViewMock.mockReturnValue({
type: "remote",
entriesIds: [],
groupedCounts: undefined,
hasNextPage: false,
isFetchingNextPage: false,
isFetching: false,
isLoading: false,
error: null,
refetch,
fetchNextPage: vi.fn(),
hasNext: false,
isRefetching: false,
isReady: true,
})
let refetchEntries: (() => void | Promise<void>) | undefined
const reset = vi.fn(() => {
events.push("reset")
})
const Consumer = () => {
const actions = useEntriesActions()
React.useEffect(() => {
actions.setOnReset(reset)
refetchEntries = actions.refetch
return () => {
actions.setOnReset(null)
}
}, [actions])
return null
}
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
await act(async () => {
root?.render(
<EntriesProvider>
<Consumer />
</EntriesProvider>,
)
})
await act(async () => {
await refetchEntries?.()
})
expect(events).toEqual(["reset", "refetch"])
})
})

View File

@ -78,7 +78,10 @@ export const EntriesProvider: React.FC<React.PropsWithChildren> = ({ children })
// Stable actions that reference latest refs
const fetchNextPageStable = useCallback(() => fetchNextPageRef.current?.(), [])
const refetchStable = useCallback(() => refetchRef.current?.(), [])
const refetchStable = useCallback(() => {
onResetRef.current?.()
return refetchRef.current?.()
}, [])
const setOnResetStable = useCallback((cb: (() => void) | null) => {
onResetRef.current = cb
}, [])

View File

@ -37,17 +37,25 @@ import { EntryRootStateContext } from "./store/EntryColumnContext"
function EntryColumnContent() {
const listRef = useRef<Virtualizer<HTMLElement, Element>>(undefined)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation()
const state = useEntriesState()
const actions = useEntriesActions()
const scrollTimelineToTop = useCallback(() => {
listRef.current?.scrollToOffset(0)
const scrollArea = scrollAreaRef.current
if (!scrollArea) return
scrollArea.scrollTop = 0
scrollArea.scrollLeft = 0
}, [])
// Register reset handler to keep scroll behavior when data resets
useEffect(() => {
actions.setOnReset(() => {
listRef.current?.scrollToIndex(0)
})
actions.setOnReset(scrollTimelineToTop)
return () => actions.setOnReset(null)
}, [actions])
}, [actions, scrollTimelineToTop])
const { entriesIds, groupedCounts } = state
useSnapEntryIdList(entriesIds)
@ -169,9 +177,17 @@ function EntryColumnContent() {
!state.error &&
(!feed || feed?.type === "feed") && <AddFeedHelper />}
<EntryListHeader refetch={actions.refetch} isRefreshing={isRefreshing} />
<EntryListHeader
refetch={actions.refetch}
isRefreshing={isRefreshing}
onBeforeRefresh={scrollTimelineToTop}
/>
<EntryColumnWrapper onScroll={handleCombinedScroll} key={`${routeFeedId}-${view}`}>
<EntryColumnWrapper
ref={scrollAreaRef}
onScroll={handleCombinedScroll}
key={`${routeFeedId}-${view}`}
>
{entriesIds.length === 0 ? (
state.isLoading ? (
<EntryItemSkeleton view={view} />

View File

@ -43,7 +43,8 @@ import { SwitchToMasonryButton } from "./buttons/SwitchToMasonryButton"
export const EntryListHeader: FC<{
refetch: () => void
isRefreshing: boolean
}> = ({ refetch, isRefreshing }) => {
onBeforeRefresh?: () => void
}> = ({ refetch, isRefreshing, onBeforeRefresh }) => {
const routerParams = useRouteParams()
const { t } = useTranslation()
@ -188,7 +189,8 @@ export const EntryListHeader: FC<{
<ActionButton
tooltip="Refresh"
onClick={() => {
refreshFeed()
onBeforeRefresh?.()
void refreshFeed()
}}
>
<RotatingRefreshIcon isRefreshing={isPending} />