From 27dfc6a61755373c3007048452d975f366af0ef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:34:08 +0200 Subject: [PATCH] fix(ui): scroll to a deep link's target once a settling body reveals A body held back until its reads settle was hidden when the page arrived, so a link such as the pill-order anchor in the insights settings had nothing to scroll to and the page stayed at the top. --- .../ui/__tests__/reveal-when-settled.test.tsx | 45 ++++++++++++++++++- src/components/ui/reveal-when-settled.tsx | 35 ++++++++++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/components/ui/__tests__/reveal-when-settled.test.tsx b/src/components/ui/__tests__/reveal-when-settled.test.tsx index 39037f967..a098c35ef 100644 --- a/src/components/ui/__tests__/reveal-when-settled.test.tsx +++ b/src/components/ui/__tests__/reveal-when-settled.test.tsx @@ -1,8 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { RevealWhenSettled } from "../reveal-when-settled"; +import { RevealWhenSettled, scrollToHashTarget } from "../reveal-when-settled"; /** * The body mounts at once (so its reads start) but stays out of layout until @@ -25,3 +25,44 @@ describe("", () => { ); }); }); + +describe("scrollToHashTarget", () => { + function setup(inside: boolean) { + const target = { scrollIntoView: vi.fn() }; + const container = { contains: vi.fn(() => inside) }; + const doc = { + getElementById: vi.fn((id: string) => + id === "insights-pill-order" ? target : null, + ), + }; + return { + target, + container, + doc: doc as unknown as Pick, + }; + } + + it("scrolls to a hash target the revealed body holds", () => { + const { target, container, doc } = setup(true); + expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe( + true, + ); + expect(target.scrollIntoView).toHaveBeenCalledWith({ block: "start" }); + }); + + it("leaves a target outside the body to the browser", () => { + const { target, container, doc } = setup(false); + expect(scrollToHashTarget(container, "#insights-pill-order", doc)).toBe( + false, + ); + expect(target.scrollIntoView).not.toHaveBeenCalled(); + }); + + it("does nothing without a hash or a target", () => { + const { container, doc } = setup(true); + expect(scrollToHashTarget(container, "", doc)).toBe(false); + expect(scrollToHashTarget(container, "#", doc)).toBe(false); + expect(scrollToHashTarget(container, "#nope", doc)).toBe(false); + expect(scrollToHashTarget(container, "#%E0%A4%A", doc)).toBe(false); + }); +}); diff --git a/src/components/ui/reveal-when-settled.tsx b/src/components/ui/reveal-when-settled.tsx index ac98626ea..dbfce7951 100644 --- a/src/components/ui/reveal-when-settled.tsx +++ b/src/components/ui/reveal-when-settled.tsx @@ -1,8 +1,30 @@ "use client"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +/** + * Scroll to the element the URL's hash names, when it sits inside + * `container`. Returns whether it scrolled. + */ +export function scrollToHashTarget( + container: Pick | null, + hash: string, + doc: Pick, +): boolean { + if (!container || hash.length < 2) return false; + let id: string; + try { + id = decodeURIComponent(hash.slice(1)); + } catch { + return false; + } + const target = doc.getElementById(id); + if (!target || !container.contains(target)) return false; + target.scrollIntoView({ block: "start" }); + return true; +} + /** * Holds a page body out of layout until its first reads have settled, then * shows it in one step. @@ -21,6 +43,10 @@ import { useIsFetching, useQueryClient } from "@tanstack/react-query"; * slow or polling read elsewhere in the app can delay it but never strand * it. Revealing is latched: a later background refetch does not hide the * body again. + * + * A deep link into the body (`/settings/layout/insights#insights-pill-order`) + * found its target hidden on arrival, so the browser had nothing to scroll + * to. Once the body shows, it scrolls to the hash target itself. */ export function RevealWhenSettled({ children, @@ -39,6 +65,12 @@ export function RevealWhenSettled({ const queryClient = useQueryClient(); const fetching = useIsFetching(); const [revealed, setRevealed] = useState(false); + const bodyRef = useRef(null); + + useEffect(() => { + if (!revealed) return; + scrollToHashTarget(bodyRef.current, window.location.hash, document); + }, [revealed]); useEffect(() => { if (revealed || fetching > 0) return; @@ -67,6 +99,7 @@ export function RevealWhenSettled({ <> {revealed ? null : fallback}