Implement scroll position management for navigation (#73)

* feat: implement scroll position management for main page navigation

* fix: import saveMainPageScrollPosition in ServerCard and ServerCardInline components

* feat: save scroll position when navigating to server from DashCommand

* fix: prevent restoring stale scroll positions without main page origin
This commit is contained in:
仓鼠
2026-06-20 16:21:32 +08:00
committed by GitHub
parent 5a4e47d520
commit 8d58f41856
19 changed files with 218 additions and 34 deletions
+2
View File
@@ -16,6 +16,7 @@ import {
import { useCommand } from "@/hooks/use-command"; import { useCommand } from "@/hooks/use-command";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { useWebSocketContext } from "@/hooks/use-websocket-context";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { formatNezhaInfo } from "@/lib/utils"; import { formatNezhaInfo } from "@/lib/utils";
export function DashCommand() { export function DashCommand() {
@@ -89,6 +90,7 @@ export function DashCommand() {
key={server.id} key={server.id}
value={server.name} value={server.name}
onSelect={() => { onSelect={() => {
saveMainPageScrollPosition();
navigate(`/server/${server.id}`); navigate(`/server/${server.id}`);
closeCommand(); closeCommand();
}} }}
+15 -6
View File
@@ -59,13 +59,22 @@ export default function GroupSwitch({
useEffect(() => { useEffect(() => {
const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)]; const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)];
const scrollContainer = scrollRef.current;
if (currentTagRef) { if (currentTagRef && scrollContainer) {
currentTagRef.scrollIntoView({ const nextScrollLeft =
behavior: "smooth", currentTagRef.offsetLeft -
block: "nearest", scrollContainer.clientWidth / 2 +
inline: "center", currentTagRef.offsetWidth / 2;
});
if (typeof scrollContainer.scrollTo === "function") {
scrollContainer.scrollTo({
behavior: "smooth",
left: Math.max(0, nextScrollLeft),
});
} else {
scrollContainer.scrollLeft = Math.max(0, nextScrollLeft);
}
} }
}, [currentTab, itemRefs, tabs]); }, [currentTab, itemRefs, tabs]);
+20 -1
View File
@@ -207,11 +207,30 @@ type links = {
name: string; name: string;
}; };
function parseCustomLinks(customLinks: string | undefined): links[] | null {
if (!customLinks) return null;
try {
const parsedLinks = JSON.parse(customLinks);
if (!Array.isArray(parsedLinks)) {
return null;
}
return parsedLinks.filter(
(link): link is links =>
typeof link?.link === "string" && typeof link?.name === "string",
);
} catch {
return null;
}
}
function Links() { function Links() {
// @ts-expect-error CustomLinks is a global variable // @ts-expect-error CustomLinks is a global variable
const customLinks = window.CustomLinks as string; const customLinks = window.CustomLinks as string;
const links: links[] | null = customLinks ? JSON.parse(customLinks) : null; const links = parseCustomLinks(customLinks);
if (!links) return null; if (!links) return null;
+2 -1
View File
@@ -2,6 +2,7 @@ import { memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import useTooltip from "@/hooks/use-tooltip"; import useTooltip from "@/hooks/use-tooltip";
import { saveMainPageScrollPosition } from "@/lib/navigation";
const MapTooltip = memo(function MapTooltip() { const MapTooltip = memo(function MapTooltip() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -46,7 +47,7 @@ const MapTooltip = memo(function MapTooltip() {
type="button" type="button"
className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white" className="flex items-center gap-1.5 py-0.5 text-neutral-500 transition-colors hover:text-black dark:text-neutral-400 dark:hover:text-white"
onClick={() => { onClick={() => {
sessionStorage.setItem("fromMainPage", "true"); saveMainPageScrollPosition();
navigate(`/server/${server.id}`); navigate(`/server/${server.id}`);
}} }}
> >
+2 -1
View File
@@ -8,6 +8,7 @@ import {
GetOsName, GetOsName,
MageMicrosoftWindows, MageMicrosoftWindows,
} from "@/lib/logo-class"; } from "@/lib/logo-class";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils"; import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api"; import type { NezhaServer } from "@/types/nezha-api";
import BillingInfo from "./billingInfo"; import BillingInfo from "./billingInfo";
@@ -40,7 +41,7 @@ export default function ServerCard({
} = formatNezhaInfo(now, serverInfo); } = formatNezhaInfo(now, serverInfo);
const cardClick = () => { const cardClick = () => {
sessionStorage.setItem("fromMainPage", "true"); saveMainPageScrollPosition();
navigate(`/server/${serverInfo.id}`); navigate(`/server/${serverInfo.id}`);
}; };
+2 -1
View File
@@ -8,6 +8,7 @@ import {
GetOsName, GetOsName,
MageMicrosoftWindows, MageMicrosoftWindows,
} from "@/lib/logo-class"; } from "@/lib/logo-class";
import { saveMainPageScrollPosition } from "@/lib/navigation";
import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils"; import { cn, formatNezhaInfo, parsePublicNote } from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api"; import type { NezhaServer } from "@/types/nezha-api";
import BillingInfo from "./billingInfo"; import BillingInfo from "./billingInfo";
@@ -41,7 +42,7 @@ export default function ServerCardInline({
} = formatNezhaInfo(now, serverInfo); } = formatNezhaInfo(now, serverInfo);
const cardClick = () => { const cardClick = () => {
sessionStorage.setItem("fromMainPage", "true"); saveMainPageScrollPosition();
navigate(`/server/${serverInfo.id}`); navigate(`/server/${serverInfo.id}`);
}; };
+6
View File
@@ -40,6 +40,12 @@ export default function ServerDetailOverview({
if (previousPath) { if (previousPath) {
setHasHistory(true); setHasHistory(true);
} }
return () => {
if (previousPath) {
sessionStorage.removeItem("fromMainPage");
}
};
}, []); }, []);
const { lastData, connected } = useWebSocketContext(); const { lastData, connected } = useWebSocketContext();
+1 -1
View File
@@ -34,7 +34,7 @@ export function ServiceTracker({ serverList }: { serverList: NezhaServer[] }) {
const totalChecks = const totalChecks =
serviceData.up.reduce((a, b) => a + b, 0) + serviceData.up.reduce((a, b) => a + b, 0) +
serviceData.down.reduce((a, b) => a + b, 0); serviceData.down.reduce((a, b) => a + b, 0);
const uptime = (totalUp / totalChecks) * 100; const uptime = totalChecks > 0 ? (totalUp / totalChecks) * 100 : 0;
const avgDelay = const avgDelay =
serviceData.delay.length > 0 serviceData.delay.length > 0
+10
View File
@@ -0,0 +1,10 @@
export function saveMainPageScrollPosition() {
const scrollPosition =
window.scrollY ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0;
sessionStorage.setItem("fromMainPage", "true");
sessionStorage.setItem("scrollPosition", String(scrollPosition));
}
-1
View File
@@ -51,7 +51,6 @@ export const fetchLoginUser = async (): Promise<LoginUserResponse> => {
) { ) {
lastestRefreshTokenAt = Date.now(); lastestRefreshTokenAt = Date.now();
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
console.log("Refreshing token with CSRF token:", csrfToken);
fetch("/api/v1/refresh-token", { fetch("/api/v1/refresh-token", {
method: "POST", method: "POST",
headers: { headers: {
+30 -20
View File
@@ -46,8 +46,9 @@ export default function Servers() {
const [showServices, setShowServices] = useState<string>("0"); const [showServices, setShowServices] = useState<string>("0");
const [showMap, setShowMap] = useState<string>("0"); const [showMap, setShowMap] = useState<string>("0");
const [inline, setInline] = useState<string>("0"); const [inline, setInline] = useState<string>("0");
const containerRef = useRef<HTMLDivElement>(null); const hasRestoredScroll = useRef(false);
const [currentGroup, setCurrentGroup] = useState<string>("All"); const [currentGroup, setCurrentGroup] = useState<string>("All");
const nezhaWsData = lastData;
const customBackgroundImage = const customBackgroundImage =
(window.CustomBackgroundImage as string) !== "" (window.CustomBackgroundImage as string) !== ""
@@ -55,19 +56,30 @@ export default function Servers() {
: undefined; : undefined;
const restoreScrollPosition = useCallback(() => { const restoreScrollPosition = useCallback(() => {
const isFromMainPage = sessionStorage.getItem("fromMainPage") === "true";
const savedPosition = sessionStorage.getItem("scrollPosition"); const savedPosition = sessionStorage.getItem("scrollPosition");
if (savedPosition && containerRef.current) { const scrollTop = savedPosition ? Number(savedPosition) : Number.NaN;
containerRef.current.scrollTop = Number(savedPosition);
if (
hasRestoredScroll.current ||
!isFromMainPage ||
!Number.isFinite(scrollTop)
) {
return;
} }
hasRestoredScroll.current = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
window.scrollTo({ top: scrollTop, left: 0, behavior: "auto" });
});
});
}, []); }, []);
const handleTagChange = (newGroup: string) => { const handleTagChange = (newGroup: string) => {
setCurrentGroup(newGroup); setCurrentGroup(newGroup);
sessionStorage.setItem("selectedGroup", newGroup); sessionStorage.setItem("selectedGroup", newGroup);
sessionStorage.setItem( sessionStorage.setItem("scrollPosition", String(window.scrollY || 0));
"scrollPosition",
String(containerRef.current?.scrollTop || 0),
);
}; };
useEffect(() => { useEffect(() => {
@@ -120,11 +132,13 @@ export default function Servers() {
useEffect(() => { useEffect(() => {
const savedGroup = sessionStorage.getItem("selectedGroup") || "All"; const savedGroup = sessionStorage.getItem("selectedGroup") || "All";
setCurrentGroup(savedGroup); setCurrentGroup(savedGroup);
}, []);
restoreScrollPosition(); useEffect(() => {
}, [restoreScrollPosition]); if (nezhaWsData) {
restoreScrollPosition();
const nezhaWsData = lastData; }
}, [nezhaWsData, restoreScrollPosition]);
const groupTabs = [ const groupTabs = [
"All", "All",
@@ -250,7 +264,9 @@ export default function Servers() {
comparison = (a.state?.uptime ?? 0) - (b.state?.uptime ?? 0); comparison = (a.state?.uptime ?? 0) - (b.state?.uptime ?? 0);
break; break;
case "system": case "system":
comparison = a.host.platform.localeCompare(b.host.platform); comparison = (a.host?.platform ?? "").localeCompare(
b.host?.platform ?? "",
);
break; break;
case "cpu": case "cpu":
comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0); comparison = (a.state?.cpu ?? 0) - (b.state?.cpu ?? 0);
@@ -433,10 +449,7 @@ export default function Servers() {
)} )}
{showServices === "1" && <ServiceTracker serverList={filteredServers} />} {showServices === "1" && <ServiceTracker serverList={filteredServers} />}
{inline === "1" && ( {inline === "1" && (
<section <section className="flex flex-col gap-2 overflow-x-scroll p-px scrollbar-hidden mt-6 server-inline-list">
ref={containerRef}
className="flex flex-col gap-2 overflow-x-scroll p-px scrollbar-hidden mt-6 server-inline-list"
>
{filteredServers.map((serverInfo) => ( {filteredServers.map((serverInfo) => (
<ServerCardInline <ServerCardInline
now={nezhaWsData.now} now={nezhaWsData.now}
@@ -447,10 +460,7 @@ export default function Servers() {
</section> </section>
)} )}
{inline === "0" && ( {inline === "0" && (
<section <section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list">
ref={containerRef}
className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6 server-card-list"
>
{filteredServers.map((serverInfo) => ( {filteredServers.map((serverInfo) => (
<ServerCard <ServerCard
now={nezhaWsData.now} now={nezhaWsData.now}
@@ -83,8 +83,14 @@ describe("DashCommand", () => {
expect(screen.getByText("edge-7")).toBeInTheDocument(); expect(screen.getByText("edge-7")).toBeInTheDocument();
expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument(); expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument();
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 256,
});
await user.click(screen.getByText("edge-7")); await user.click(screen.getByText("edge-7"));
expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7"); expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7");
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(sessionStorage.getItem("scrollPosition")).toBe("256");
expect(dashMocks.closeCommand).toHaveBeenCalled(); expect(dashMocks.closeCommand).toHaveBeenCalled();
await user.click(screen.getByText("ToggleDarkMode")); await user.click(screen.getByText("ToggleDarkMode"));
+13
View File
@@ -179,6 +179,19 @@ describe("Header", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("ignores invalid custom links instead of crashing", async () => {
Object.assign(window, {
CustomLinks: "{bad-json",
});
renderHeader();
expect(await screen.findByText("Nezha")).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Docs" }),
).not.toBeInTheDocument();
});
it("stores and removes the active custom background", async () => { it("stores and removes the active custom background", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
headerMocks.backgroundImage = "/desktop.png"; headerMocks.backgroundImage = "/desktop.png";
@@ -104,6 +104,7 @@ describe("Tab and group switches", () => {
it("scrolls overflowing group tabs and applies custom background styling", async () => { it("scrolls overflowing group tabs and applies custom background styling", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const setCurrentTab = vi.fn(); const setCurrentTab = vi.fn();
const scrollIntoView = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320); vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120); vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120);
window.CustomBackgroundImage = "/background.jpg"; window.CustomBackgroundImage = "/background.jpg";
@@ -125,6 +126,7 @@ describe("Tab and group switches", () => {
expect(scrollContainer.scrollLeft).toBe(42); expect(scrollContainer.scrollLeft).toBe(42);
expect(setCurrentTab).toHaveBeenCalledWith("Asia"); expect(setCurrentTab).toHaveBeenCalledWith("Asia");
expect(scrollIntoView).not.toHaveBeenCalled();
expect( expect(
container.querySelector(".relative.flex.items-center")?.className, container.querySelector(".relative.flex.items-center")?.className,
).toContain("bg-stone-100/70"); ).toContain("bg-stone-100/70");
@@ -72,9 +72,14 @@ describe("ServerCard", () => {
screen.getAllByText(/billingInfo.remaining: 16/).length, screen.getAllByText(/billingInfo.remaining: 16/).length,
).toBeGreaterThan(0); ).toBeGreaterThan(0);
Object.defineProperty(window, "scrollY", {
configurable: true,
value: 432,
});
fireEvent.click(screen.getByText("edge-online")); fireEvent.click(screen.getByText("edge-online"));
expect(sessionStorage.getItem("fromMainPage")).toBe("true"); expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(sessionStorage.getItem("scrollPosition")).toBe("432");
expect(screen.getByText("/server/7")).toBeInTheDocument(); expect(screen.getByText("/server/7")).toBeInTheDocument();
}); });
@@ -99,6 +99,7 @@ describe("ServerDetailOverview", () => {
it("renders server identity, hardware, traffic, and temperature details", async () => { it("renders server identity, hardware, traffic, and temperature details", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
sessionStorage.setItem("fromMainPage", "true");
seedWebSocketData({ seedWebSocketData({
server: createServer({ server: createServer({
id: 7, id: 7,
@@ -113,8 +114,8 @@ describe("ServerDetailOverview", () => {
}), }),
}); });
render( const { unmount } = render(
<MemoryRouter initialEntries={["/server/7"]}> <MemoryRouter initialEntries={["/", "/server/7"]} initialIndex={1}>
<ServerDetailOverview server_id="7" /> <ServerDetailOverview server_id="7" />
<LocationProbe /> <LocationProbe />
</MemoryRouter>, </MemoryRouter>,
@@ -139,5 +140,8 @@ describe("ServerDetailOverview", () => {
await user.click(screen.getByText("edge-detail")); await user.click(screen.getByText("edge-detail"));
expect(screen.getByText("/")).toBeInTheDocument(); expect(screen.getByText("/")).toBeInTheDocument();
unmount();
expect(sessionStorage.getItem("fromMainPage")).toBeNull();
}); });
}); });
@@ -93,6 +93,31 @@ describe("ServiceTracker", () => {
expect(screen.getByText("Monthly")).toBeInTheDocument(); expect(screen.getByText("Monthly")).toBeInTheDocument();
expect(screen.queryByText("hidden-server")).not.toBeInTheDocument(); expect(screen.queryByText("hidden-server")).not.toBeInTheDocument();
}); });
it("falls back to zero uptime when a service has no checks", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP Ping",
current_up: 0,
current_down: 0,
total_up: 0,
total_down: 0,
delay: [],
up: [0, 0],
down: [0, 0],
},
},
},
});
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(await screen.findByText("HTTP Ping")).toBeInTheDocument();
expect(screen.getByText("0.0% serviceTracker.uptime")).toBeInTheDocument();
});
}); });
describe("CycleTransferStatsCard", () => { describe("CycleTransferStatsCard", () => {
+4
View File
@@ -81,6 +81,9 @@ describe("nezha api fetchers", () => {
}); });
it("refreshes the token when a logged-in browser session has cookies", async () => { it("refreshes the token when a logged-in browser session has cookies", async () => {
const consoleLog = vi
.spyOn(console, "log")
.mockImplementation(() => undefined);
Object.defineProperty(document, "cookie", { Object.defineProperty(document, "cookie", {
configurable: true, configurable: true,
value: "nezha_token=token; nz-csrf=test-csrf-token", value: "nezha_token=token; nz-csrf=test-csrf-token",
@@ -108,5 +111,6 @@ describe("nezha api fetchers", () => {
"X-CSRF-Token": "test-csrf-token", "X-CSRF-Token": "test-csrf-token",
}, },
}); });
expect(consoleLog).not.toHaveBeenCalled();
}); });
}); });
+67
View File
@@ -275,6 +275,73 @@ describe("Servers page", () => {
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha"); expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
}); });
it("sorts by system even when a platform value is missing", async () => {
const missingPlatform = createServer({
id: 1,
name: "alpha",
host: { platform: undefined },
});
const linux = createServer({
id: 2,
name: "beta",
host: { platform: "linux" },
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastData: websocketPayload([missingPlatform, linux]),
});
await user.selectOptions(screen.getByLabelText("Sort metric"), "system");
const cards = screen.getAllByTestId("server-card");
expect(cards).toHaveLength(2);
expect(cards[0]).toHaveTextContent("beta");
expect(cards[1]).toHaveTextContent("alpha");
});
it("restores the saved main page scroll position after data is ready", async () => {
const scrollTo = vi.fn();
vi.stubGlobal("scrollTo", scrollTo);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 0;
});
sessionStorage.setItem("fromMainPage", "true");
sessionStorage.setItem("scrollPosition", "345");
renderServerPage({
connected: true,
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
});
await waitFor(() => {
expect(scrollTo).toHaveBeenCalledWith({
top: 345,
left: 0,
behavior: "auto",
});
});
});
it("does not restore stale scroll positions without a main page origin", () => {
const scrollTo = vi.fn();
vi.stubGlobal("scrollTo", scrollTo);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 0;
});
sessionStorage.setItem("scrollPosition", "345");
renderServerPage({
connected: true,
lastData: websocketPayload([createServer({ id: 1, name: "alpha" })]),
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("toggles map and service tracker controls when service data exists", async () => { it("toggles map and service tracker controls when service data exists", async () => {
apiMocks.fetchService.mockResolvedValue({ apiMocks.fetchService.mockResolvedValue({
success: true, success: true,