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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent ecf56c6a1f
commit 27dfc6a617
2 files changed
+77 -3

No files matched your search

@@ -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("<RevealWhenSettled>", () => {
);
});
});
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<Document, "getElementById">,
};
}
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);
});
});
+34 -1
View File
@@ -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<Element, "contains"> | null,
hash: string,
doc: Pick<Document, "getElementById">,
): 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<HTMLDivElement | null>(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}
<div
ref={bodyRef}
data-slot="reveal-when-settled"
data-revealed={revealed ? "true" : "false"}
aria-busy={revealed ? undefined : true}