Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions webapp/src/hooks/scroll/useScrollPersistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Copyright (c) 2026, RTE (https://www.rte-france.com)
*
* See AUTHORS.txt
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*
* This file is part of the Antares project.
*/

import { useCallback, useEffect, useRef } from "react";

interface ScrollPersistence {
/** Enqueue a scroll position for `rAF`-throttled write to `sessionStorage`. */
save: (scrollTop: number) => void;
/** Return the persisted scroll position, or `null` if absent / invalid. */
restore: () => number | null;
}

/**
* Low-level persistence engine shared by all scroll-restoration hooks.
*
* Writes are `rAF`-throttled and flushed synchronously on
* `visibilitychange → "hidden"`, `beforeunload`, and unmount / key change.
*
* @param key - Unique identifier for the scrollable region.
* Pass `undefined` to disable persistence entirely.
* @returns A `ScrollPersistence` handle, or `null` when `key` is not provided.
*/
export function useScrollPersistence(key: string | undefined): ScrollPersistence | null {
const animationFrameId = useRef<number | null>(null);
const pendingScrollTop = useRef<number | null>(null);
const storageKeyRef = useRef<string | null>(null);

const storageKey = key ? `scroll-restore:${key}` : null;
storageKeyRef.current = storageKey;

useEffect(() => {
if (!storageKey) {
return;
}

// Capture storageKey so cleanup always flushes to the key that was active
// when this effect ran, even if the key has since changed.
const flush = () => {
if (pendingScrollTop.current !== null) {
sessionStorage.setItem(storageKey, String(pendingScrollTop.current));
pendingScrollTop.current = null;
}
};

// visibilitychange → "hidden" is the most reliable save-point on modern
// browsers. See https://developer.chrome.com/blog/page-lifecycle-api
const handleVisibilityChange = () => {
if (document.visibilityState === "hidden") {
flush();
}
};

document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", flush); // fallback for older browsers

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", flush);

if (animationFrameId.current !== null) {
cancelAnimationFrame(animationFrameId.current);
}

flush(); // persist the last position on unmount / key change
};
}, [storageKey]);

const save = useCallback((scrollTop: number) => {
pendingScrollTop.current = scrollTop;

if (animationFrameId.current !== null) {
cancelAnimationFrame(animationFrameId.current);
}

animationFrameId.current = requestAnimationFrame(() => {
const currentStorageKey = storageKeyRef.current;
if (currentStorageKey !== null && pendingScrollTop.current !== null) {
sessionStorage.setItem(currentStorageKey, String(pendingScrollTop.current));
pendingScrollTop.current = null;
}
});
}, []);

const restore = useCallback((): number | null => {
const currentStorageKey = storageKeyRef.current;

if (!currentStorageKey) {
return null;
}

const storedValue = sessionStorage.getItem(currentStorageKey);

if (storedValue === null) {
return null;
}

const value = Number(storedValue);
return Number.isFinite(value) ? value : null;
}, []);

return storageKey ? { save, restore } : null;
}
61 changes: 61 additions & 0 deletions webapp/src/hooks/scroll/useScrollRestoration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It’s still needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not used at that moment, but good to keep for later if we need scroll restoration on any scrollable DOM element

* Copyright (c) 2026, RTE (https://www.rte-france.com)
*
* See AUTHORS.txt
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*
* This file is part of the Antares project.
*/

import { useCallback } from "react";
import { useScrollPersistence } from "./useScrollPersistence";

/**
* Persists and restores the scroll position of a scrollable DOM element
* via `sessionStorage` (rAF-throttled writes, Page Lifecycle flush).
*
* Attach the returned ref callback to any scrollable element:
* ```tsx
* const scrollRef = useScrollRestoration("my-list");
* <div ref={scrollRef} style={{ overflow: "auto" }}>…</div>
* ```
*
* The scroll listener is registered as `{ passive: true }`.
*
* @param key - Unique, stable key for this scrollable region. Pass `undefined` to disable.
* @returns A ref callback to attach to a scrollable `HTMLElement`.
*/
export function useScrollRestoration(key: string | undefined): React.RefCallback<HTMLElement> {
const persistence = useScrollPersistence(key);

return useCallback<React.RefCallback<HTMLElement>>(
(element) => {
if (!element || !persistence) {
return;
}

const { save, restore } = persistence;

const scrollTop = restore();
if (scrollTop !== null) {
element.scrollTo({ top: scrollTop, behavior: "instant" });
}

const handleScroll = () => save(element.scrollTop);
element.addEventListener("scroll", handleScroll, { passive: true });

// Returning a function registers it as cleanup called when
// the element is detached or the ref callback identity changes.
return () => element.removeEventListener("scroll", handleScroll);
},
// Intentionally depend on `persistence`: a key change produces a new callback
// identity, causing React to run the previous cleanup then re-invoke with
// the element, triggering a fresh restore cycle.
[persistence],
);
}
56 changes: 56 additions & 0 deletions webapp/src/hooks/scroll/useScrollRestorationOS.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2026, RTE (https://www.rte-france.com)
*
* See AUTHORS.txt
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*
* This file is part of the Antares project.
*/

import type { EventListeners } from "overlayscrollbars";
import { useMemo } from "react";
import { useScrollPersistence } from "./useScrollPersistence";

/**
* Persists and restores the scroll position of an `OverlayScrollbars` container
* via `sessionStorage`. Pass the returned `EventListeners` to `CustomScrollbar`'s
* `events` prop:
*
* ```tsx
* const scrollEvents = useScrollRestorationOS("my-panel");
* <CustomScrollbar events={scrollEvents}>…</CustomScrollbar>
* ```
*
* @param key - Unique, stable ID for this scrollable region. `undefined` disables persistence.
* @returns OverlayScrollbars `EventListeners` to spread onto `CustomScrollbar`, or `undefined`.
*/
export function useScrollRestorationOS(key: string | undefined): EventListeners | undefined {
const persistence = useScrollPersistence(key);

return useMemo<EventListeners | undefined>(() => {
if (!persistence) {
return undefined;
}

const { save, restore } = persistence;

return {
initialized(instance) {
const scrollTop = restore();

if (scrollTop !== null) {
instance.elements().scrollOffsetElement.scrollTo({ top: scrollTop, behavior: "instant" });
}
},

scroll(instance) {
save(instance.elements().scrollOffsetElement.scrollTop);
},
};
}, [persistence]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import CustomScrollbar from "@/components/CustomScrollbar";
import { useScrollRestorationOS } from "@/hooks/scroll/useScrollRestorationOS";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import HomeIcon from "@mui/icons-material/Home";
import { Box, IconButton, Stack, Tooltip, Typography } from "@mui/material";
Expand All @@ -36,6 +37,8 @@ interface Props {
onToggleCollapse: () => void;
onAddDirectory?: () => void;
onRootClick?: () => void;
/** sessionStorage key used to persist and restore scroll position across navigations. */
scrollKey?: string;
children: React.ReactNode;
}

Expand All @@ -46,10 +49,12 @@ function TreeSection({
onToggleCollapse,
onAddDirectory,
onRootClick,
scrollKey,
children,
}: Props) {
const { container, iconColor, titleColor } = variantStyles[variant];
const { t } = useTranslation();
const scrollEvents = useScrollRestorationOS(scrollKey);

////////////////////////////////////////////////////////////////
// JSX
Expand Down Expand Up @@ -103,7 +108,9 @@ function TreeSection({
{/* Content row — collapses via 0fr grid row in parent */}
<Box sx={[container, contentSxOverride]}>
<Box sx={innerContentSx}>
<CustomScrollbar style={scrollbarStyle}>{children}</CustomScrollbar>
<CustomScrollbar style={scrollbarStyle} events={scrollEvents}>
{children}
</CustomScrollbar>
</Box>
</Box>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function StudyTree() {
onToggleCollapse={() => handleToggleCollapse("managed")}
onRootClick={handleManagedRootClick}
onAddDirectory={handleAddDirectoryClick}
scrollKey="study-tree-managed"
>
<ManagedTree
isCreatingDirectory={isCreatingDirectory}
Expand All @@ -102,6 +103,7 @@ function StudyTree() {
icon={<StorageIcon />}
onToggleCollapse={() => handleToggleCollapse("external")}
onRootClick={handleExternalRootClick}
scrollKey="study-tree-external"
>
<ExternalTree studies={externalStudies} onRootClick={handleExternalRootClick} />
</TreeSection>
Expand Down
Loading