From 72aca22723fbbac739daa58ea02fc6d75440e285 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Wed, 3 Jun 2026 17:06:24 +0700 Subject: [PATCH] fix(faq): scroll direct hash links --- src/views/faq/__tests__/faq.test.tsx | 66 ++++++++++++++++++++++++++++ src/views/faq/faq.tsx | 16 +++++-- 2 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/views/faq/__tests__/faq.test.tsx diff --git a/src/views/faq/__tests__/faq.test.tsx b/src/views/faq/__tests__/faq.test.tsx new file mode 100644 index 00000000..48c704c6 --- /dev/null +++ b/src/views/faq/__tests__/faq.test.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import FAQ from '../faq'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; + +vi.mock('react-router-hash-link', () => ({ + HashLink: ({ children, to }: { children: React.ReactNode; to: string }) => createElement('a', { href: to }, children), +})); + +vi.mock('../../home/home', () => ({ + Footer: () => createElement('div', { 'data-testid': 'footer' }, 'footer'), + HomeLogo: () => createElement('div', { 'data-testid': 'home-logo' }, 'home-logo'), +})); + +let container: HTMLDivElement; +let root: Root; +let scrollIntoViewMock: ReturnType; + +const renderFAQ = async (initialEntry = '/faq') => { + await act(async () => { + root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(FAQ))); + }); +}; + +describe('FAQ', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.scrollTo = vi.fn(); + scrollIntoViewMock = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoViewMock as unknown as typeof Element.prototype.scrollIntoView; + document.title = 'before'; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('scrolls to the FAQ hash target on direct hash routes', async () => { + await renderFAQ('/faq#sage'); + + const sageQuestion = document.getElementById('sage'); + expect(sageQuestion?.textContent).toBe('What is "sage"?'); + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + expect(scrollIntoViewMock.mock.contexts[0]).toBe(sageQuestion); + expect(window.scrollTo).not.toHaveBeenCalled(); + expect(document.title).toBe('FAQ - 5chan'); + }); + + it('scrolls to the top when there is no FAQ hash target', async () => { + await renderFAQ(); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + expect(document.title).toBe('FAQ - 5chan'); + }); +}); diff --git a/src/views/faq/faq.tsx b/src/views/faq/faq.tsx index b04c7483..18b642b1 100644 --- a/src/views/faq/faq.tsx +++ b/src/views/faq/faq.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { HashLink } from 'react-router-hash-link'; import { Footer, HomeLogo } from '../home/home'; import styles from './faq.module.css'; @@ -788,10 +788,20 @@ const FAQ_SECTIONS: FAQSection[] = [ ]; const FAQ = () => { + const { hash } = useLocation(); + useEffect(() => { - window.scrollTo(0, 0); document.title = 'FAQ - 5chan'; - }, []); + + const anchorId = hash.startsWith('#') ? hash.slice(1) : ''; + const anchorElement = anchorId ? document.getElementById(anchorId) : null; + if (anchorElement) { + anchorElement.scrollIntoView(); + return; + } + + window.scrollTo(0, 0); + }, [hash]); return (