Skip to content

Commit 85bdf1a

Browse files
authored
🐛(frontend) preserve scroll position across renders (#578)
The thread list lost its scroll position when mailbox stats changed (triggering a query invalidation) or when navigating between the thread list and thread view pages (component unmount/remount). Introduce a ScrollRestoreProvider in MainLayout that stores scroll positions by identifier. The useScrollRestore hook saves the position on scroll and restores it after mount or data-driven re-renders, only when the contextKey (mailbox + search params) matches so that folder or mailbox switches correctly start at the top.
1 parent 16500d2 commit 85bdf1a

File tree

4 files changed

+96
-15
lines changed

4 files changed

+96
-15
lines changed

src/frontend/src/features/layouts/components/main/index.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,22 @@ import { NoMailbox } from "./no-mailbox";
66
import { SentBoxProvider } from "@/features/providers/sent-box";
77
import { LeftPanel } from "./left-panel";
88
import { ModalStoreProvider } from "@/features/providers/modal-store";
9+
import { ScrollRestoreProvider } from "@/features/providers/scroll-restore";
910
import { useTheme } from "@/features/providers/theme";
1011
import Link from "next/link";
1112

1213
export const MainLayout = ({ children }: PropsWithChildren) => {
1314
return (
1415
<AuthenticatedView>
15-
<MailboxProvider>
16-
<SentBoxProvider>
17-
<ModalStoreProvider>
18-
<MainLayoutContent>{children}</MainLayoutContent>
19-
</ModalStoreProvider>
20-
</SentBoxProvider>
21-
</MailboxProvider>
16+
<ScrollRestoreProvider>
17+
<MailboxProvider>
18+
<SentBoxProvider>
19+
<ModalStoreProvider>
20+
<MainLayoutContent>{children}</MainLayoutContent>
21+
</ModalStoreProvider>
22+
</SentBoxProvider>
23+
</MailboxProvider>
24+
</ScrollRestoreProvider>
2225
</AuthenticatedView>
2326
)
2427
}

src/frontend/src/features/layouts/components/thread-panel/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import Image from "next/image";
1111
import useAbility, { Abilities } from "@/hooks/use-ability";
1212
import ThreadPanelHeader from "./components/thread-panel-header";
1313
import { useThreadSelection } from "@/features/providers/thread-selection";
14+
import { useScrollRestore } from "@/features/providers/scroll-restore";
1415

1516
export const ThreadPanel = () => {
1617
const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext();
@@ -19,6 +20,10 @@ export const ThreadPanel = () => {
1920
const { t } = useTranslation();
2021
const loaderRef = useRef<HTMLDivElement>(null);
2122
const canImportMessages = useAbility(Abilities.CAN_IMPORT_MESSAGES, selectedMailbox);
23+
const scrollContextKey = `${selectedMailbox?.id}:${searchParams.toString()}`;
24+
const { containerRef: scrollContainerRef, onScroll: handleScroll } = useScrollRestore(
25+
'thread-list', scrollContextKey, [threads],
26+
);
2227

2328
const {
2429
selectedThreadIds,
@@ -100,7 +105,7 @@ export const ThreadPanel = () => {
100105
onEnableSelectionMode={enableSelectionMode}
101106
onDisableSelectionMode={clearSelection}
102107
/>
103-
<div className="thread-panel__threads_list">
108+
<div className="thread-panel__threads_list" ref={scrollContainerRef} onScroll={handleScroll}>
104109
{threads?.results.map((thread) => (
105110
<ThreadItem
106111
key={thread.id}

src/frontend/src/features/providers/mailbox.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,6 @@ type MessageQueryInvalidationSource = {
3131
* Messages after this date keep their current state.
3232
*/
3333
readAt?: string | null;
34-
/**
35-
* Skip the threads query invalidation (refetch).
36-
* Useful when optimistic updates are sufficient (e.g. read flag)
37-
* to avoid scroll position reset in the thread list.
38-
*/
39-
skipThreadsInvalidation?: boolean;
4034
}
4135

4236
type MailboxContextType = {
@@ -377,7 +371,7 @@ export const MailboxProvider = ({ children }: PropsWithChildren) => {
377371
mailboxes: mailboxQuery.error,
378372
threads: threadsQuery.error,
379373
messages: messagesQuery.error,
380-
}
374+
},
381375
};
382376

383377
useEffect(() => {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createContext, PropsWithChildren, useCallback, useContext, useLayoutEffect, useRef } from "react";
2+
3+
type ScrollState = { position: number; contextKey: string };
4+
5+
type ScrollRestoreContextType = {
6+
getScrollState: (scrollId: string) => ScrollState;
7+
setScrollState: (scrollId: string, state: ScrollState) => void;
8+
};
9+
10+
const ScrollRestoreContext = createContext<ScrollRestoreContextType | null>(null);
11+
12+
export const ScrollRestoreProvider = ({ children }: PropsWithChildren) => {
13+
const statesRef = useRef<Record<string, ScrollState>>({});
14+
15+
const getScrollState = (scrollId: string): ScrollState => {
16+
return statesRef.current[scrollId] ?? { position: 0, contextKey: '' };
17+
};
18+
19+
const setScrollState = (scrollId: string, state: ScrollState) => {
20+
statesRef.current[scrollId] = state;
21+
};
22+
23+
return (
24+
<ScrollRestoreContext.Provider value={{ getScrollState, setScrollState }}>
25+
{children}
26+
</ScrollRestoreContext.Provider>
27+
);
28+
};
29+
30+
/**
31+
* Persists and restores scroll position of a container across
32+
* unmount/remount cycles (e.g. page navigations within the same layout).
33+
* Must be used within a ScrollRestoreProvider.
34+
*
35+
* @param scrollId A unique identifier for this scroll container.
36+
* @param contextKey Identifies the current view (e.g. mailbox + folder).
37+
* Scroll is only restored when the saved contextKey matches.
38+
* @param deps Extra dependencies that should trigger a restore attempt
39+
* (e.g. the data rendered inside the container).
40+
*/
41+
export const useScrollRestore = (
42+
scrollId: string,
43+
contextKey: string,
44+
deps: unknown[] = [],
45+
) => {
46+
const context = useContext(ScrollRestoreContext);
47+
if (!context) {
48+
throw new Error("`useScrollRestore` must be used within a `ScrollRestoreProvider`.");
49+
}
50+
const { getScrollState, setScrollState } = context;
51+
const containerRef = useRef<HTMLDivElement>(null);
52+
53+
const onScroll = useCallback(() => {
54+
if (containerRef.current) {
55+
setScrollState(scrollId, {
56+
position: containerRef.current.scrollTop,
57+
contextKey,
58+
});
59+
}
60+
}, [scrollId, contextKey, setScrollState]);
61+
62+
useLayoutEffect(() => {
63+
const el = containerRef.current;
64+
if (!el) return;
65+
const saved = getScrollState(scrollId);
66+
if (saved.contextKey === contextKey) {
67+
// Same context: restore saved position (if any)
68+
if (saved.position > 0 && el.scrollTop === 0) {
69+
el.scrollTop = saved.position;
70+
}
71+
} else {
72+
// Context changed (e.g. folder switch): reset to top
73+
setScrollState(scrollId, { position: 0, contextKey });
74+
el.scrollTop = 0;
75+
}
76+
}, [scrollId, contextKey, getScrollState, setScrollState, ...deps]); // eslint-disable-line react-hooks/exhaustive-deps
77+
78+
return { containerRef, onScroll };
79+
}

0 commit comments

Comments
 (0)