diff --git a/src/visualBuilder/eventManager/__test__/useScrollToHashAnchor.test.ts b/src/visualBuilder/eventManager/__test__/useScrollToHashAnchor.test.ts new file mode 100644 index 00000000..42bfe1df --- /dev/null +++ b/src/visualBuilder/eventManager/__test__/useScrollToHashAnchor.test.ts @@ -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; + + 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(); + }); +}); diff --git a/src/visualBuilder/eventManager/useScrollToHashAnchor.ts b/src/visualBuilder/eventManager/useScrollToHashAnchor.ts new file mode 100644 index 00000000..dfdcfdfb --- /dev/null +++ b/src/visualBuilder/eventManager/useScrollToHashAnchor.ts @@ -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( + `[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 }); +}; diff --git a/src/visualBuilder/index.ts b/src/visualBuilder/index.ts index 7040bba7..a9c96fc9 100644 --- a/src/visualBuilder/index.ts +++ b/src/visualBuilder/index.ts @@ -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, @@ -363,6 +364,7 @@ export class VisualBuilder { resizeObserver: this.resizeObserver, }); useScrollToField(); + useScrollToHashAnchor(); useHighlightCommentIcon(); this.mutationObserver.observe(document.body, { @@ -396,7 +398,7 @@ export class VisualBuilder { ); - + useHideFocusOverlayPostMessageEvent({ overlayWrapper: this.overlayWrapper, visualBuilderContainer: this.visualBuilderContainer,