fix(desktop): scroll timeline to top before refresh
This commit is contained in:
parent
431ab0e0be
commit
8557b9f158
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue