Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
} from "vitest";
import { useScrollToHashAnchor } from "../useScrollToHashAnchor";

const setLocationHash = (hash: string) => {
Object.defineProperty(window, "location", {
writable: true,
value: { ...window.location, hash, href: `http://localhost/${hash}` },
});
};

describe("useScrollToHashAnchor", () => {
let scrollSpy: ReturnType<typeof vi.fn>;

beforeEach(() => {
document.body.innerHTML = "";
scrollSpy = vi.fn();
// jsdom doesn't implement scrollIntoView.
Element.prototype.scrollIntoView = scrollSpy as unknown as (
arg?: boolean | ScrollIntoViewOptions
) => void;
setLocationHash("");
});

afterEach(() => {
vi.useRealTimers();
});

it("does nothing when there is no hash", () => {
setLocationHash("");
const el = document.createElement("div");
el.id = "footer";
document.body.appendChild(el);

useScrollToHashAnchor();

expect(scrollSpy).not.toHaveBeenCalled();
});

it("ignores hash-router URLs starting with #/", () => {
setLocationHash("#/dashboard");
const el = document.createElement("div");
el.id = "dashboard";
document.body.appendChild(el);

useScrollToHashAnchor();

expect(scrollSpy).not.toHaveBeenCalled();
});

it("ignores hashbang router URLs starting with #!/", () => {
setLocationHash("#!/products");
const el = document.createElement("div");
el.id = "products";
document.body.appendChild(el);

useScrollToHashAnchor();

expect(scrollSpy).not.toHaveBeenCalled();
});

it("scrolls immediately when the anchor element already exists", () => {
setLocationHash("#footer");
const el = document.createElement("div");
el.id = "footer";
document.body.appendChild(el);

useScrollToHashAnchor();

expect(scrollSpy).toHaveBeenCalledTimes(1);
expect(scrollSpy).toHaveBeenCalledWith({
behavior: "smooth",
block: "start",
});
});

it("waits for the anchor element to appear via MutationObserver", () => {
// jsdom's MutationObserver microtask flushing is unreliable in vitest,
// so we stub it and trigger the callback manually.
let observerCallback: MutationCallback | null = null;
const disconnect = vi.fn();
const observe = vi.fn();
const OriginalMO = global.MutationObserver;
class StubMutationObserver {
constructor(cb: MutationCallback) {
observerCallback = cb;
}
observe = observe;
disconnect = disconnect;
takeRecords = () => [];
}
global.MutationObserver =
StubMutationObserver as unknown as typeof MutationObserver;

try {
setLocationHash("#footer");
useScrollToHashAnchor();

expect(scrollSpy).not.toHaveBeenCalled();
expect(observe).toHaveBeenCalledTimes(1);

// Element appears later (SPA hydration), observer fires.
const el = document.createElement("div");
el.id = "footer";
document.body.appendChild(el);
observerCallback?.([], {} as MutationObserver);

expect(scrollSpy).toHaveBeenCalledTimes(1);
expect(disconnect).toHaveBeenCalledTimes(1);
} finally {
global.MutationObserver = OriginalMO;
}
});

it("falls back to elements queried by name attribute (legacy anchors)", () => {
setLocationHash("#footer");
const a = document.createElement("a");
a.setAttribute("name", "footer");
document.body.appendChild(a);

useScrollToHashAnchor();

expect(scrollSpy).toHaveBeenCalledTimes(1);
});

it("decodes percent-encoded ids before lookup", () => {
setLocationHash("#contact%20us");
const el = document.createElement("div");
el.id = "contact us";
document.body.appendChild(el);

useScrollToHashAnchor();

expect(scrollSpy).toHaveBeenCalledTimes(1);
});

it("stops observing after the timeout when the element never appears", async () => {
vi.useFakeTimers();
setLocationHash("#never");

useScrollToHashAnchor();
expect(scrollSpy).not.toHaveBeenCalled();

// Past the 5s safety timeout — observer should disconnect.
vi.advanceTimersByTime(6000);
vi.useRealTimers();

// Add the element after the timeout — observer is disconnected, so
// no scroll should fire.
const el = document.createElement("div");
el.id = "never";
document.body.appendChild(el);

await new Promise((resolve) => setTimeout(resolve, 0));

expect(scrollSpy).not.toHaveBeenCalled();
});
});
54 changes: 54 additions & 0 deletions src/visualBuilder/eventManager/useScrollToHashAnchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const SCROLL_TIMEOUT_MS = 5000;

const findAnchor = (id: string): HTMLElement | null => {
const byId = document.getElementById(id);
if (byId) return byId;
try {
return document.querySelector<HTMLElement>(
`[name="${CSS.escape(id)}"]`
);
} catch {
return null;
}
};

export const useScrollToHashAnchor = (): void => {
const rawHash = window.location.hash;
if (!rawHash || rawHash.length < 2) return;
// Hash-router URLs (#/route, #!/route) are routes, not anchors.
if (rawHash.startsWith("#/") || rawHash.startsWith("#!/")) return;

let id: string;
try {
id = decodeURIComponent(rawHash.slice(1));
} catch {
id = rawHash.slice(1);
}
if (!id) return;

const scrollTo = (el: HTMLElement) => {
el.scrollIntoView({ behavior: "smooth", block: "start" });
};

const existing = findAnchor(id);
if (existing) {
scrollTo(existing);
return;
}

// User site may be an SPA — wait for the element to appear in the DOM.
const observer = new MutationObserver(() => {
const el = findAnchor(id);
if (el) {
observer.disconnect();
clearTimeout(timeoutId);
scrollTo(el);
}
});

const timeoutId = window.setTimeout(() => {
observer.disconnect();
}, SCROLL_TIMEOUT_MS);

observer.observe(document.body, { childList: true, subtree: true });
};
4 changes: 3 additions & 1 deletion src/visualBuilder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import initUI from "./components";
import { useDraftFieldsPostMessageEvent } from "./eventManager/useDraftFieldsPostMessageEvent";
import { useHideFocusOverlayPostMessageEvent } from "./eventManager/useHideFocusOverlayPostMessageEvent";
import { useScrollToField } from "./eventManager/useScrollToField";
import { useScrollToHashAnchor } from "./eventManager/useScrollToHashAnchor";
import { debounceAddVariantFieldClass, getHighlightVariantFieldsStatus, setHighlightVariantFields, useVariantFieldsPostMessageEvent } from "./eventManager/useVariantsPostMessageEvent";
import {
generateEmptyBlocks,
Expand Down Expand Up @@ -363,6 +364,7 @@ export class VisualBuilder {
resizeObserver: this.resizeObserver,
});
useScrollToField();
useScrollToHashAnchor();
useHighlightCommentIcon();

this.mutationObserver.observe(document.body, {
Expand Down Expand Up @@ -396,7 +398,7 @@ export class VisualBuilder {
);



useHideFocusOverlayPostMessageEvent({
overlayWrapper: this.overlayWrapper,
visualBuilderContainer: this.visualBuilderContainer,
Expand Down
Loading