Compare commits

...

2 Commits

Author SHA1 Message Date
Greg Bergé 5eba878657 wip 2025-04-13 10:02:36 +02:00
Greg Bergé f29d2b8e9c wip 2025-04-13 09:06:01 +02:00
10 changed files with 260 additions and 76 deletions
+10 -10
View File
@@ -4018,7 +4018,7 @@
"gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"gitbook-v2/next": ["next@15.3.1-canary.6", "", { "dependencies": { "@next/env": "15.3.1-canary.6", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.6", "@next/swc-darwin-x64": "15.3.1-canary.6", "@next/swc-linux-arm64-gnu": "15.3.1-canary.6", "@next/swc-linux-arm64-musl": "15.3.1-canary.6", "@next/swc-linux-x64-gnu": "15.3.1-canary.6", "@next/swc-linux-x64-musl": "15.3.1-canary.6", "@next/swc-win32-arm64-msvc": "15.3.1-canary.6", "@next/swc-win32-x64-msvc": "15.3.1-canary.6", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mw5jVmLEGeDeuRqATKUw/ldoYTqKlz7/Zn8EPaxpOny/yKrpxVCpW8z+ji/jCm8SmyvLYuyn743ll/6tInzCzQ=="],
"gitbook-v2/next": ["next@15.3.1-canary.7", "", { "dependencies": { "@next/env": "15.3.1-canary.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.1-canary.7", "@next/swc-darwin-x64": "15.3.1-canary.7", "@next/swc-linux-arm64-gnu": "15.3.1-canary.7", "@next/swc-linux-arm64-musl": "15.3.1-canary.7", "@next/swc-linux-x64-gnu": "15.3.1-canary.7", "@next/swc-linux-x64-musl": "15.3.1-canary.7", "@next/swc-win32-arm64-msvc": "15.3.1-canary.7", "@next/swc-win32-x64-msvc": "15.3.1-canary.7", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-RblvC7A7k8cKpSa6JSCbaatb6JAncv2niUSH7TqXt6xHUoItnxa3Fu7xkducFJQdcW2Hv2h0+NBu6kaKf0S4zw=="],
"global-dirs/ini": ["ini@1.3.7", "", {}, "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ=="],
@@ -4886,23 +4886,23 @@
"gaxios/https-proxy-agent/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"gitbook-v2/next/@next/env": ["@next/env@15.3.1-canary.6", "", {}, "sha512-RZuPfUpY1Vcc9qFEGgmrl7PlcU+G2e0AZzTGL+amv3y0PXpC6Bp6dNGUSckUAxrzh6j3IQEKSM1cnselxFjCNg=="],
"gitbook-v2/next/@next/env": ["@next/env@15.3.1-canary.7", "", {}, "sha512-QSzAlzxaGrQYj3Nzgxd35WM6pmxmNk9lOCn0nsNLPidvc3H6wj/fV3Pl8/tVCYQP61luGuA0Ib0VwZc8ja119w=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.1-canary.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-boVBCyPE9Q8QOkgR/JqBzPqmRnPedvzRZVO8/TU388jdNcB2sGhYSdlLAdn3/j3byyi0ne/YN31OwPq/FYdtMw=="],
"gitbook-v2/next/@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.1-canary.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fs7JYU0MKtpgsDBIvbT/wn1MwKP8C+bqcb6IjWnDtVcciFKzYDs/tPCBW6CtYg1wpHqm9horZDA1ytb/sAghxA=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.1-canary.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-GDiTNrwTB9a6843PBAvKvD7+j6Zvy8PdMQNyF5Q0u58m29s+M9wP2ie0+3wdIdaLX/H2cVKnFGP5fAMzTsAjaw=="],
"gitbook-v2/next/@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.1-canary.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Efl6es/IEXJf5groF78fc1PrTEwK9bOyi6fRjp1bsa/AWOHp8rTc3l0T6EeY5T0SWeYilisZZa9rax7nTgw4bw=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.1-canary.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-UUQAAe1vrFWsE94vbmCUMQk3YfOvGAlD+mbwFN5vHprgANvaSoL+kMxnF4K/IqjEltuGaNQyP63omyvPoOvXnQ=="],
"gitbook-v2/next/@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.1-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-IS71I8z2wlecRZRHXqWTtxVozZt1h9IH/2MX1P/WkCjQH98acFmCkw0jo/y70mrZ4lw6tRGTNkSBMun+5P5P+w=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.1-canary.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-jbpLxohCU9VhWBNvIFr2rLDWIqBhmGI4ODSLffqI6dApIc92q+rLYS3YMsu9VWOd1/ivoKpXT1yAWqkfYiJxGQ=="],
"gitbook-v2/next/@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.1-canary.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-uJ4env9fZxIFM+aRBCjNeG91HHZ2cRXiF25xJqGmCC1Y5jT5oYAB/tIBCFRSj6NRzdt4pZAamCMRS41WzzVVUQ=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.1-canary.6", "", { "os": "linux", "cpu": "x64" }, "sha512-0UKu7pOOXi7Kn7rylskI7f4kT57icLnVA2X8y4U1LHs5KOU03uRA9h8ofQgWXgbQigPdBwR1Kn/APmYOyqNWZg=="],
"gitbook-v2/next/@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.1-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-/+efTL2TxoSAYCxdf+g6WvDKIJDoaETvF3I+lxpixhctfrIwpr2qnG0e6u2M55k1iYiiyFIOA63fpIsGnIMJZA=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.1-canary.6", "", { "os": "linux", "cpu": "x64" }, "sha512-FRTdzhAX5uKb6YQ/o4ykWt3vrpfg1PIOaLh8rriA/DDzzTTBEL/JSMc5Xc/DpnbRJhA/OUHMkNW8Y29qjezKhA=="],
"gitbook-v2/next/@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.1-canary.7", "", { "os": "linux", "cpu": "x64" }, "sha512-Hb+agfULOjEhJAmq/r9OTVAo8KxFlnuCT7BEUbSuVP/Gxn883al6juSG7ZZfk7P4a+ZU6iVYyHKIFAKCarZmUA=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.1-canary.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-SrTGmFwMHagT1CYXTn/xtJFfjuBcYBZNKipmdg8k7mv9ROB5rO47Xeb7tzmbtCL2JQtP2mDg8blIHCfo5HnkwA=="],
"gitbook-v2/next/@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.1-canary.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-gNd5Vq7k9xIEQj85JoavHGRsyvu1zKOjVoPUYtsZRxXw6kcBg84x2g8NeT/3SmwH1PiFU/sjQXtRzr2Kuc2j3A=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.1-canary.6", "", { "os": "win32", "cpu": "x64" }, "sha512-uofQ0olsdSketGLIgTEgXAh9UH4YJK2i1+IJxq+U1H/cqN3yvk9CQ8wrgo9cyKQ1oA/D5IVqzszPGlV9UV8SXQ=="],
"gitbook-v2/next/@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.1-canary.7", "", { "os": "win32", "cpu": "x64" }, "sha512-D5i+KinvNNmspXfgIvEZ1RFOKVhOgdtoSC7/xEUm55hEkcWFVHOtVVPvgV7WUeo1elNxbgxytzmyKbLaifP03g=="],
"gitbook-v2/next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -1,51 +1,65 @@
'use client';
import { Icon } from '@gitbook/icons';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useRef } from 'react';
import { tString, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { usePathnameChange } from '../hooks';
import { useScrollListener } from '../hooks/useScrollListener';
const globalClassName = 'navigation-open';
const SCROLL_DISTANCE = 320;
/**
* Detect if the user has scrolled in the page, meaning the TOC is not visible.
*/
function useHasScrolledRef() {
const hasScrollRef = useRef(false);
const windowRef = useRef(typeof window === 'undefined' ? null : window);
useScrollListener(() => {
hasScrollRef.current = window.scrollY >= 320;
}, windowRef);
return hasScrollRef
}
/**
* Button to show/hide the table of content on mobile.
*/
export function HeaderMobileMenu(props: Partial<React.ButtonHTMLAttributes<HTMLButtonElement>>) {
const language = useLanguage();
const hasScrollRef = useHasScrolledRef();
const pathname = usePathname();
const hasScrollRef = useRef(false);
// Close the navigation when navigating to a page
usePathnameChange(() => {
document.body.classList.remove(globalClassName);
});
const toggleNavigation = () => {
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
document.body.classList.remove(globalClassName);
} else {
const handleNavigationClick = () => {
// If already opened
if (document.body.classList.contains(globalClassName)) {
// If the user has scrolled down, we need to scroll back to the top.
if (hasScrollRef.current) {
window.scrollTo(0, 0);
}
// Else we close the table of contents
else {
document.body.classList.remove(globalClassName);
}
}
// If not opened, we open it an scroll to the top
else {
document.body.classList.add(globalClassName);
window.scrollTo(0, 0);
}
};
const windowRef = useRef(typeof window === 'undefined' ? null : window);
useScrollListener(() => {
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
}, windowRef);
// Close the navigation when navigating to a page
useEffect(() => {
document.body.classList.remove(globalClassName);
}, [pathname]);
};
return (
<button
{...props}
aria-label={tString(language, 'table_of_contents_button_label')}
onClick={toggleNavigation}
onClick={handleNavigationClick}
className={tcls(
'flex flex-row items-center rounded straight-corners:rounded-sm px-2 py-1',
props.className
@@ -1,18 +1,19 @@
'use client';
import { motion } from 'framer-motion';
import React from 'react';
import React, { useRef } from 'react';
import { useScrollActiveId } from '@/components/hooks';
// import { useScrollActiveId } from '@/components/hooks';
import type { DocumentSection } from '@/lib/document-sections';
import { tcls } from '@/lib/tailwind';
import { HEADER_HEIGHT_DESKTOP } from '../layout';
import { usePageActiveSections } from '../hooks/usePageActiveSections';
// import { HEADER_HEIGHT_DESKTOP } from '../layout';
import { AsideSectionHighlight } from './AsideSectionHighlight';
/**
* The threshold at which we consider a section as intersecting the viewport.
*/
const SECTION_INTERSECTING_THRESHOLD = 0.9;
// const SECTION_INTERSECTING_THRESHOLD = 0.9;
const springCurve = {
type: 'spring',
@@ -30,10 +31,13 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
});
}, [sections]);
const activeId = useScrollActiveId(ids, {
rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
threshold: SECTION_INTERSECTING_THRESHOLD,
});
const windowRef = useRef(typeof window === 'undefined' ? null : window);
const activeIds = usePageActiveSections(ids, windowRef);
// const activeId = useScrollActiveId(ids, {
// rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
// threshold: SECTION_INTERSECTING_THRESHOLD,
// });
return (
<ul className={tcls('sidebar-list-line:border-l', 'border-tint-subtle')}>
@@ -49,7 +53,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
section.depth > 1 && ['ml-3', 'my-0', 'sidebar-list-line:ml-0']
)}
>
{activeId === section.id ? (
{activeIds.includes(section.id) ? (
<AsideSectionHighlight
transition={springCurve}
className={tcls(
@@ -102,7 +106,7 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
'sidebar-list-default:border-tint',
],
activeId === section.id && [
activeIds.includes(section.id) && [
'text-primary-subtle',
'hover:text-primary',
'contrast-more:text-primary',
@@ -4,3 +4,5 @@ export * from './useHash';
export * from './useIsMounted';
export * from './useToggleAnimation';
export * from './useCurrentPagePath';
export * from './usePrevious';
export * from './usePathnameChange';
@@ -1,24 +1,26 @@
'use client';
import { useParams } from 'next/navigation';
import React from 'react';
import { useCallback, useEffect, useState } from 'react';
function getHash(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.location.hash.slice(1);
}
/**
* Hook to get the current hash from the URL.
* @see https://github.com/vercel/next.js/discussions/49465
*/
// How do I get the pathname with hash.
// source: https://github.com/vercel/next.js/discussions/49465
export function useHash() {
const params = useParams();
const [hash, setHash] = React.useState<string | null>(getHash);
React.useEffect(() => {
setHash(getHash());
}, [params]);
const getCurrentHash = useCallback(
() => (typeof window !== 'undefined' ? window.location.hash.replace(/^#!?/, '') : ''),
[]
);
const [hash, setHash] = useState<string>(getCurrentHash());
useEffect(() => {
const handleHashChange = () => {
setHash(getCurrentHash());
};
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, [getCurrentHash]);
return hash;
}
@@ -0,0 +1,128 @@
'use client';
import * as React from 'react';
import { useEventCallback } from 'usehooks-ts';
import { useScrollListener } from './useScrollListener';
/**
* Hook that returns the active section key of the page.
* The active section is the section that is currently visible on the viewport.
*
* Here's a diagram: https://user-images.githubusercontent.com/8937991/206921462-14e5b8b7-5a19-4c8b-912d-ed1f17710bc4.png
*/
export function usePageActiveSections(
ids: string[],
scrollRef: React.RefObject<HTMLElement | Window | null>
): string[] {
const [activeSections, setActiveSections] = React.useState<string[]>(noActiveSections);
const update = useEventCallback(() => {
const element = scrollRef.current;
if (element) {
const rect =
element instanceof Window
? new DOMRect(0, 0, window.innerWidth, window.innerHeight)
: element.getBoundingClientRect();
const activeSections = findActiveSectionIds({
ids,
containerRect: rect,
});
setActiveSections((previous) => {
if (activeSections.length !== previous.length) {
return activeSections;
}
if (activeSections.some((key, i) => key !== previous[i])) {
return activeSections;
}
return previous;
});
}
});
useScrollListener(update, scrollRef);
React.useEffect(() => {
update();
}, [update]);
return activeSections;
}
const noActiveSections: string[] = [];
// We consider anything in [-MARGINpx; +MARGINpx] to be at the boundary
const MARGIN = 24;
/**
* findActiveSectionIndex finds the section that is in the window's view
* it uses binary-search to efficiently (minimize DOM queries)
* find the DOM node whose .top is closest to but below `0` (the window's top)
*/
function findActiveSectionIndex(ids: string[], scrollTop: number): number {
const N = ids.length;
// Number of max iterations to find the element (so our search is bounded)
// in theory, at most floor(log(n)+1) steps to find the specific element
const maxIterations = Math.floor(Math.log2(N) + 1);
// Our binary search variables
let start = 0;
let end = N - 1;
let mid = Math.ceil((start + end) / 2);
let idx = -1;
let y: number | null = null;
for (let i = 0; i < maxIterations; i++) {
const id = ids[mid];
// Double check that we have a section, else exit
if (!id) {
return -1;
}
const element = document.getElementById(id);
const sy = element ? element.getBoundingClientRect().top - scrollTop : null;
if (sy === null || sy > MARGIN) {
// Go left
end = mid;
mid = Math.floor((start + end) / 2);
} else if (sy >= -MARGIN && sy <= MARGIN) {
// Hit
return mid;
} else if (sy < MARGIN) {
// If bigger than previous (but still smaller than 0)
// that becomes our new active index
if (y === null || sy > y) {
y = sy;
idx = mid;
}
// Go right
start = mid;
mid = Math.ceil((start + end) / 2);
}
}
return idx;
}
/**
* Find the active sections in the viewport.
*/
function findActiveSectionIds(input: {
ids: string[];
containerRect: DOMRect;
}): string[] {
const activeIndex = findActiveSectionIndex(input.ids, input.containerRect.top);
// Check if the next sections are visible, stop when they are not.
const activeSections: string[] = input.ids[activeIndex] ? [input.ids[activeIndex]] : [];
for (let i = activeIndex + 1; i < input.ids.length; i++) {
const id = input.ids[i];
const element = document.getElementById(id);
if (!element) {
continue;
}
const rect = element.getBoundingClientRect();
if (rect.bottom + MARGIN > input.containerRect.bottom) {
break;
}
activeSections.push(id);
}
return activeSections;
}
@@ -0,0 +1,19 @@
'use client';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import { useEventCallback } from 'usehooks-ts';
import { usePrevious } from './usePrevious';
/**
* Returns the value of the previous render.
*/
export function usePathnameChange(callback: () => void): void {
const pathname = usePathname();
const previousPathname = usePrevious(pathname);
const eventCallback = useEventCallback(callback);
React.useEffect(() => {
if (previousPathname !== undefined && pathname !== previousPathname) {
eventCallback();
}
}, [pathname, previousPathname, eventCallback]);
}
@@ -0,0 +1,13 @@
'use client';
import * as React from 'react';
/**
* Returns the value of the previous render.
*/
export function usePrevious<T>(value: T): T | undefined {
const ref = React.useRef<T | undefined>(undefined);
React.useLayoutEffect(() => {
ref.current = value;
});
return ref.current;
}
@@ -17,7 +17,7 @@ import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react';
*/
export function useScrollListener(
listener: (event: Event) => void,
elementRef: RefObject<HTMLElement | Window> | null
elementRef: RefObject<HTMLElement | Window | null> | null
) {
const listenerRef = useRef(listener);
useLayoutEffect(() => {
@@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation';
import React from 'react';
import { useHash } from './useHash';
import { usePrevious } from './usePrevious';
/**
* Scroll the page to an anchor point or
@@ -13,28 +14,29 @@ import { useHash } from './useHash';
export function useScrollPage(props: { scrollMarginTop?: number }) {
const hash = useHash();
const pathname = usePathname();
const prevPathname = usePrevious(pathname);
const scrollMarginTopRef = React.useRef(props.scrollMarginTop);
React.useLayoutEffect(() => {
if (hash) {
const element = document.getElementById(hash);
if (element) {
if (props.scrollMarginTop) {
element.style.scrollMarginTop = `${props.scrollMarginTop}px`;
const originalScrollMarginTop = element.style.scrollMarginTop;
if (scrollMarginTopRef.current) {
element.style.scrollMarginTop = `${scrollMarginTopRef.current}px`;
}
element.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
element.scrollIntoView({ block: 'start', behavior: 'smooth' });
return () => {
const element = document.getElementById(hash);
if (element) {
element.style.scrollMarginTop = originalScrollMarginTop;
}
};
}
} else {
return;
}
if (prevPathname && pathname !== prevPathname) {
window.scrollTo(0, 0);
}
return () => {
if (hash) {
const element = document.getElementById(hash);
if (element) {
element.style.scrollMarginTop = '';
}
}
};
}, [hash, pathname, props.scrollMarginTop]);
}, [hash, pathname, prevPathname]);
}