fix(faq): scroll direct hash links

This commit is contained in:
Tommaso Casaburi
2026-06-03 17:06:24 +07:00
parent 188c44a923
commit 72aca22723
2 changed files with 79 additions and 3 deletions
+66
View File
@@ -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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
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<typeof vi.fn>;
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');
});
});
+13 -3
View File
@@ -1,5 +1,5 @@
import { type ReactNode, useEffect } from 'react'; 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 { HashLink } from 'react-router-hash-link';
import { Footer, HomeLogo } from '../home/home'; import { Footer, HomeLogo } from '../home/home';
import styles from './faq.module.css'; import styles from './faq.module.css';
@@ -788,10 +788,20 @@ const FAQ_SECTIONS: FAQSection[] = [
]; ];
const FAQ = () => { const FAQ = () => {
const { hash } = useLocation();
useEffect(() => { useEffect(() => {
window.scrollTo(0, 0);
document.title = 'FAQ - 5chan'; 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 ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>