From 4ed88d19970e504ed506ab1bcb393e534bca2337 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Wed, 2 Oct 2024 18:51:02 +0300 Subject: [PATCH] docs: DX and performance improvements in API reference (#9430) - Improve scroll behavior between active sections - Improve lag when clicking on a sidebar item - Refactor internal working of the `SidebarProvider` to find active items faster. - Use Next.js's `useRouter` hook for changing the hash (since they added the option to disable scroll) - Change `isBrowser` from a hook to a provider since it's widely used across applications. - Other general improvements and fixes. Closes DOCS-952 --- .../components/Section/index.tsx | 6 +- .../components/Tags/Operation/index.tsx | 18 ++-- .../components/Tags/Section/Schema/index.tsx | 4 +- .../components/Tags/Section/index.tsx | 24 +++-- .../api-reference/components/Tags/index.tsx | 14 ++- www/apps/api-reference/providers/index.tsx | 27 ++---- www/apps/api-reference/providers/main-nav.tsx | 2 +- .../api-reference/providers/page-title.tsx | 5 +- .../utils/check-element-in-viewport.ts | 5 +- www/apps/book/components/Feedback/index.tsx | 2 +- www/apps/book/providers/index.tsx | 53 +++++------ www/apps/book/providers/main-nav.tsx | 2 +- .../resources/components/Feedback/index.tsx | 2 +- www/apps/resources/providers/index.tsx | 57 +++++------ www/apps/resources/providers/main-nav.tsx | 2 +- www/apps/ui/src/providers/index.tsx | 23 ++--- www/apps/ui/src/providers/main-nav.tsx | 2 +- .../user-guide/components/Feedback/index.tsx | 2 +- www/apps/user-guide/providers/main-nav.tsx | 2 +- .../docs-ui/src/components/Bannerv2/index.tsx | 2 +- .../src/components/ChildDocs/index.tsx | 6 +- .../components/MainNav/Breadcrumb/index.tsx | 5 +- .../src/components/RootProviders/index.tsx | 25 +++++ .../components/Sidebar/Item/Link/index.tsx | 14 ++- .../docs-ui/src/components/Toc/index.tsx | 2 +- .../src/components/TypeList/Items/index.tsx | 2 +- www/packages/docs-ui/src/components/index.ts | 1 + www/packages/docs-ui/src/hooks/index.ts | 1 - .../src/hooks/use-active-on-scroll/index.tsx | 9 +- .../src/hooks/use-click-outside/index.tsx | 2 +- .../docs-ui/src/hooks/use-is-browser/index.ts | 13 --- .../docs-ui/src/layouts/main-content.tsx | 2 +- www/packages/docs-ui/src/layouts/root.tsx | 20 ++-- .../src/providers/BrowserProvider/index.tsx | 41 ++++++++ .../src/providers/LearningPath/index.tsx | 5 +- .../docs-ui/src/providers/Sidebar/index.tsx | 94 +++++++++++++------ www/packages/docs-ui/src/providers/index.ts | 1 + www/yarn.lock | 15 +-- 38 files changed, 294 insertions(+), 218 deletions(-) create mode 100644 www/packages/docs-ui/src/components/RootProviders/index.tsx delete mode 100644 www/packages/docs-ui/src/hooks/use-is-browser/index.ts create mode 100644 www/packages/docs-ui/src/providers/BrowserProvider/index.tsx diff --git a/www/apps/api-reference/components/Section/index.tsx b/www/apps/api-reference/components/Section/index.tsx index 3965279bc2db9..a6be97f98e07f 100644 --- a/www/apps/api-reference/components/Section/index.tsx +++ b/www/apps/api-reference/components/Section/index.tsx @@ -2,6 +2,7 @@ import clsx from "clsx" import { useActiveOnScroll, useSidebar } from "docs-ui" +import { useRouter } from "next/navigation" import { useEffect, useRef } from "react" export type SectionProps = { @@ -20,6 +21,7 @@ const Section = ({ useDefaultIfNoActive: false, }) const { setActivePath } = useSidebar() + const router = useRouter() useEffect(() => { if ("scrollRestoration" in history) { @@ -30,7 +32,9 @@ const Section = ({ useEffect(() => { if (activeItemId.length) { - history.pushState({}, "", `#${activeItemId}`) + router.push(`#${activeItemId}`, { + scroll: false, + }) setActivePath(activeItemId) } }, [activeItemId]) diff --git a/www/apps/api-reference/components/Tags/Operation/index.tsx b/www/apps/api-reference/components/Tags/Operation/index.tsx index 0db2a0290e08b..ec014f383d34f 100644 --- a/www/apps/api-reference/components/Tags/Operation/index.tsx +++ b/www/apps/api-reference/components/Tags/Operation/index.tsx @@ -12,6 +12,7 @@ import type { TagOperationCodeSectionProps } from "./CodeSection" import TagsOperationDescriptionSection from "./DescriptionSection" import DividedLayout from "@/layouts/Divided" import { useLoading } from "@/providers/loading" +import { useRouter } from "next/navigation" import SectionDivider from "../../Section/Divider" import checkElementInViewport from "../../../utils/check-element-in-viewport" @@ -33,7 +34,8 @@ const TagOperation = ({ endpointPath, className, }: TagOperationProps) => { - const { setActivePath } = useSidebar() + const { activePath, setActivePath } = useSidebar() + const router = useRouter() const [show, setShow] = useState(false) const path = useMemo( () => getSectionId([...(operation.tags || []), operation.operationId]), @@ -57,10 +59,14 @@ const TagOperation = ({ } setShow(true) } - // can't use next router as it doesn't support - // changing url without scrolling - history.replaceState({}, "", `#${path}`) - setActivePath(path) + if (location.hash !== path) { + router.push(`#${path}`, { + scroll: false, + }) + } + if (activePath !== path) { + setActivePath(path) + } } }, }) @@ -77,7 +83,7 @@ const TagOperation = ({ ) const scrollIntoView = useCallback(() => { - if (nodeRef.current && !checkElementInViewport(nodeRef.current, 10)) { + if (nodeRef.current && !checkElementInViewport(nodeRef.current, 0)) { const elm = nodeRef.current as HTMLElement scrollToTop( elm.offsetTop + (elm.offsetParent as HTMLElement)?.offsetTop, diff --git a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx index cd2415341d244..0801d5463061e 100644 --- a/www/apps/api-reference/components/Tags/Section/Schema/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/Schema/index.tsx @@ -72,7 +72,7 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { useEffect(() => { if (schemaSlug === (activePath || location.hash.replace("#", ""))) { const elm = document.getElementById(schemaSlug) as HTMLElement - if (!checkElementInViewport(elm, 40)) { + if (!checkElementInViewport(elm, 0)) { scrollToElement(elm) } } @@ -85,7 +85,7 @@ const TagSectionSchema = ({ schema, tagName }: TagSectionSchemaProps) => { const section = entry.target if ( - (inView || checkElementInViewport(section, 40)) && + (inView || checkElementInViewport(section, 10)) && activePath !== schemaSlug ) { // can't use next router as it doesn't support diff --git a/www/apps/api-reference/components/Tags/Section/index.tsx b/www/apps/api-reference/components/Tags/Section/index.tsx index 80437156d4fb4..42527c0860a30 100644 --- a/www/apps/api-reference/components/Tags/Section/index.tsx +++ b/www/apps/api-reference/components/Tags/Section/index.tsx @@ -20,7 +20,7 @@ import { useArea } from "@/providers/area" import SectionDivider from "../../Section/Divider" import clsx from "clsx" import { Feedback, Loading, Link } from "docs-ui" -import { usePathname } from "next/navigation" +import { usePathname, useRouter } from "next/navigation" import formatReportLink from "@/utils/format-report-link" import { SchemaObject, TagObject } from "@/types/openapi" import useSWR from "swr" @@ -49,11 +49,12 @@ const MDXContentClient = dynamic( const TagSection = ({ tag }: TagSectionProps) => { const { activePath, setActivePath } = useSidebar() + const router = useRouter() const [loadPaths, setLoadPaths] = useState(false) const slugTagName = useMemo(() => getSectionId([tag.name]), [tag]) const { area } = useArea() const pathname = usePathname() - const { scrollableElement, scrollToElement } = useScrollController() + const { scrollableElement, scrollToTop } = useScrollController() const { data } = useSWR<{ schema: SchemaObject }>( @@ -84,10 +85,14 @@ const TagSection = ({ tag }: TagSectionProps) => { // ensure that the hash link doesn't change if it links to an inner path const currentHashArr = location.hash.replace("#", "").split("_") if (currentHashArr.length < 2 || currentHashArr[0] !== slugTagName) { - // can't use next router as it doesn't support - // changing url without scrolling - history.replaceState({}, "", `#${slugTagName}`) - setActivePath(slugTagName) + if (location.hash !== slugTagName) { + router.push(`#${slugTagName}`, { + scroll: false, + }) + } + if (activePath !== slugTagName) { + setActivePath(slugTagName) + } } } }, @@ -98,8 +103,11 @@ const TagSection = ({ tag }: TagSectionProps) => { const tagName = activePath.split("_") if (tagName.length === 1 && tagName[0] === slugTagName) { const elm = document.getElementById(tagName[0]) - if (elm && !checkElementInViewport(elm, 10)) { - scrollToElement(elm) + if (elm && !checkElementInViewport(elm, 0)) { + scrollToTop( + elm.offsetTop + (elm.offsetParent as HTMLElement)?.offsetTop, + 0 + ) } } else if (tagName.length > 1 && tagName[0] === slugTagName) { setLoadPaths(true) diff --git a/www/apps/api-reference/components/Tags/index.tsx b/www/apps/api-reference/components/Tags/index.tsx index bdb2a8aa8633d..bd680d7e03567 100644 --- a/www/apps/api-reference/components/Tags/index.tsx +++ b/www/apps/api-reference/components/Tags/index.tsx @@ -13,6 +13,7 @@ import { ExpandedDocument } from "@/types/openapi" import getTagChildSidebarItems from "@/utils/get-tag-child-sidebar-items" import { SidebarItem, SidebarItemSections } from "types" import basePathUrl from "../../utils/base-path-url" +import { useRouter } from "next/navigation" const TagSection = dynamic( async () => import("./Section") @@ -31,8 +32,9 @@ const Tags = () => { const [loadData, setLoadData] = useState(false) const [expand, setExpand] = useState("") const { baseSpecs, setBaseSpecs } = useBaseSpecs() - const { addItems, setActivePath } = useSidebar() + const { activePath, addItems, setActivePath } = useSidebar() const { area, prevArea } = useArea() + const router = useRouter() const { data } = useSWR( loadData && !baseSpecs @@ -89,8 +91,14 @@ const Tags = () => { children: childItems, loaded: childItems.length > 0, onOpen: () => { - history.pushState({}, "", `#${tagPathName}`) - setActivePath(tagPathName) + if (location.hash !== tagPathName) { + router.push(`#${tagPathName}`, { + scroll: false, + }) + } + if (activePath !== tagPathName) { + setActivePath(tagPathName) + } }, }) }) diff --git a/www/apps/api-reference/providers/index.tsx b/www/apps/api-reference/providers/index.tsx index d93af89dc4193..a160fe596ab34 100644 --- a/www/apps/api-reference/providers/index.tsx +++ b/www/apps/api-reference/providers/index.tsx @@ -2,9 +2,6 @@ import { AnalyticsProvider, - ColorModeProvider, - MobileProvider, - ModalProvider, PageLoadingProvider, ScrollControllerProvider, SiteConfigProvider, @@ -24,21 +21,15 @@ const Providers = ({ children }: ProvidersProps) => { - - - - - - - - {children} - - - - - - - + + + + + {children} + + + + diff --git a/www/apps/api-reference/providers/main-nav.tsx b/www/apps/api-reference/providers/main-nav.tsx index a4bff7d19a09e..afe9e60ba458a 100644 --- a/www/apps/api-reference/providers/main-nav.tsx +++ b/www/apps/api-reference/providers/main-nav.tsx @@ -16,7 +16,7 @@ type MainNavProviderProps = { } export const MainNavProvider = ({ children }: MainNavProviderProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const navigationDropdownItems = useMemo( () => diff --git a/www/apps/api-reference/providers/page-title.tsx b/www/apps/api-reference/providers/page-title.tsx index 2bf3876857119..ec004ebb8e38b 100644 --- a/www/apps/api-reference/providers/page-title.tsx +++ b/www/apps/api-reference/providers/page-title.tsx @@ -12,7 +12,7 @@ type PageTitleProviderProps = { } const PageTitleProvider = ({ children }: PageTitleProviderProps) => { - const { activePath, getActiveItem } = useSidebar() + const { activePath, activeItem } = useSidebar() const { area } = useArea() useEffect(() => { @@ -21,7 +21,6 @@ const PageTitleProvider = ({ children }: PageTitleProviderProps) => { if (!activePath?.length) { document.title = titleSuffix } else { - const activeItem = getActiveItem() if (activeItem?.path === activePath) { document.title = `${activeItem?.title} - ${titleSuffix}` } else { @@ -34,7 +33,7 @@ const PageTitleProvider = ({ children }: PageTitleProviderProps) => { } } } - }, [activePath, area, getActiveItem]) + }, [activePath, area, activeItem]) return ( diff --git a/www/apps/api-reference/utils/check-element-in-viewport.ts b/www/apps/api-reference/utils/check-element-in-viewport.ts index 4f7a1cdaf7f76..a3bbf202afb09 100644 --- a/www/apps/api-reference/utils/check-element-in-viewport.ts +++ b/www/apps/api-reference/utils/check-element-in-viewport.ts @@ -1,11 +1,10 @@ export default function checkElementInViewport( element: Element, - percentage = 100, - height?: number + percentage = 100 ) { const rect = element.getBoundingClientRect() const windowHeight: number | undefined = - height || window.innerHeight || document.documentElement.clientHeight + window.innerHeight || document.documentElement.clientHeight return !( Math.floor(100 - ((rect.top >= 0 ? 0 : rect.top) / +-rect.height) * 100) < diff --git a/www/apps/book/components/Feedback/index.tsx b/www/apps/book/components/Feedback/index.tsx index 2b9b6686ad6e7..9a4ca89f131f8 100644 --- a/www/apps/book/components/Feedback/index.tsx +++ b/www/apps/book/components/Feedback/index.tsx @@ -15,7 +15,7 @@ type FeedbackProps = Omit const Feedback = (props: FeedbackProps) => { const pathname = usePathname() - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const feedbackPathname = useMemo(() => basePathUrl(pathname), [pathname]) const reportLink = useMemo( diff --git a/www/apps/book/providers/index.tsx b/www/apps/book/providers/index.tsx index 42f375cfc3df0..e157fd811d9c9 100644 --- a/www/apps/book/providers/index.tsx +++ b/www/apps/book/providers/index.tsx @@ -2,11 +2,8 @@ import { AnalyticsProvider, - ColorModeProvider, HooksLoader, LearningPathProvider, - MobileProvider, - ModalProvider, NotificationProvider, PaginationProvider, ScrollControllerProvider, @@ -25,34 +22,28 @@ const Providers = ({ children }: ProvidersProps) => { return ( - - - - - - - - - - - - {children} - - - - - - - - - - - + + + + + + + + + {children} + + + + + + + + ) diff --git a/www/apps/book/providers/main-nav.tsx b/www/apps/book/providers/main-nav.tsx index 670c9d574c15e..b756862a1563f 100644 --- a/www/apps/book/providers/main-nav.tsx +++ b/www/apps/book/providers/main-nav.tsx @@ -17,7 +17,7 @@ type MainNavProviderProps = { } export const MainNavProvider = ({ children }: MainNavProviderProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const navigationDropdownItems = useMemo( () => diff --git a/www/apps/resources/components/Feedback/index.tsx b/www/apps/resources/components/Feedback/index.tsx index 897f6c3a4bde1..af8952b221f0d 100644 --- a/www/apps/resources/components/Feedback/index.tsx +++ b/www/apps/resources/components/Feedback/index.tsx @@ -15,7 +15,7 @@ type FeedbackProps = Omit export const Feedback = (props: FeedbackProps) => { const pathname = usePathname() - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const feedbackPathname = useMemo(() => basePathUrl(pathname), [pathname]) const reportLink = useMemo( diff --git a/www/apps/resources/providers/index.tsx b/www/apps/resources/providers/index.tsx index 58fbeefcc4abd..ae55b911c5fc1 100644 --- a/www/apps/resources/providers/index.tsx +++ b/www/apps/resources/providers/index.tsx @@ -2,11 +2,8 @@ import { AnalyticsProvider, - ColorModeProvider, HooksLoader, LearningPathProvider, - MobileProvider, - ModalProvider, NotificationProvider, PaginationProvider, ScrollControllerProvider, @@ -25,36 +22,30 @@ const Providers = ({ children }: ProvidersProps) => { return ( - - - - - - - - - - - - {children} - - - - - - - - - - - + + + + + + + + + {children} + + + + + + + + ) diff --git a/www/apps/resources/providers/main-nav.tsx b/www/apps/resources/providers/main-nav.tsx index ea04fedd310e4..2a356f4c5f4cf 100644 --- a/www/apps/resources/providers/main-nav.tsx +++ b/www/apps/resources/providers/main-nav.tsx @@ -17,7 +17,7 @@ type MainNavProviderProps = { } export const MainNavProvider = ({ children }: MainNavProviderProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const navigationDropdownItems = useMemo( () => diff --git a/www/apps/ui/src/providers/index.tsx b/www/apps/ui/src/providers/index.tsx index 20cab2fc49d4d..66c98dda96460 100644 --- a/www/apps/ui/src/providers/index.tsx +++ b/www/apps/ui/src/providers/index.tsx @@ -1,9 +1,6 @@ "use client" import { - ColorModeProvider, - MobileProvider, - ModalProvider, AnalyticsProvider, ScrollControllerProvider, SiteConfigProvider, @@ -21,19 +18,13 @@ const Providers = ({ children }: ProvidersProps) => { return ( - - - - - - - {children} - - - - - - + + + + {children} + + + ) diff --git a/www/apps/ui/src/providers/main-nav.tsx b/www/apps/ui/src/providers/main-nav.tsx index ce60c4d9b2f89..81162494c37e3 100644 --- a/www/apps/ui/src/providers/main-nav.tsx +++ b/www/apps/ui/src/providers/main-nav.tsx @@ -15,7 +15,7 @@ type MainNavProviderProps = { } export const MainNavProvider = ({ children }: MainNavProviderProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const navigationDropdownItems = useMemo( () => getNavDropdownItems({ diff --git a/www/apps/user-guide/components/Feedback/index.tsx b/www/apps/user-guide/components/Feedback/index.tsx index 2b9b6686ad6e7..9a4ca89f131f8 100644 --- a/www/apps/user-guide/components/Feedback/index.tsx +++ b/www/apps/user-guide/components/Feedback/index.tsx @@ -15,7 +15,7 @@ type FeedbackProps = Omit const Feedback = (props: FeedbackProps) => { const pathname = usePathname() - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const feedbackPathname = useMemo(() => basePathUrl(pathname), [pathname]) const reportLink = useMemo( diff --git a/www/apps/user-guide/providers/main-nav.tsx b/www/apps/user-guide/providers/main-nav.tsx index 9bd9970e2d9db..264bf9a88bed4 100644 --- a/www/apps/user-guide/providers/main-nav.tsx +++ b/www/apps/user-guide/providers/main-nav.tsx @@ -15,7 +15,7 @@ type MainNavProviderProps = { } export const MainNavProvider = ({ children }: MainNavProviderProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const navigationDropdownItems = useMemo( () => getNavDropdownItems({ diff --git a/www/packages/docs-ui/src/components/Bannerv2/index.tsx b/www/packages/docs-ui/src/components/Bannerv2/index.tsx index 14b51cf6ae534..d0da40a740d73 100644 --- a/www/packages/docs-ui/src/components/Bannerv2/index.tsx +++ b/www/packages/docs-ui/src/components/Bannerv2/index.tsx @@ -13,7 +13,7 @@ export type Bannerv2Props = { export const Bannerv2 = ({ className }: Bannerv2Props) => { const [show, setShow] = useState(false) - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() useEffect(() => { if (!isBrowser) { diff --git a/www/packages/docs-ui/src/components/ChildDocs/index.tsx b/www/packages/docs-ui/src/components/ChildDocs/index.tsx index 5464716a47bbe..5be8ca4fdbb2d 100644 --- a/www/packages/docs-ui/src/components/ChildDocs/index.tsx +++ b/www/packages/docs-ui/src/components/ChildDocs/index.tsx @@ -22,7 +22,7 @@ export const ChildDocs = ({ hideTitle = false, childLevel = 1, }: ChildDocsProps) => { - const { currentItems, getActiveItem } = useSidebar() + const { currentItems, activeItem } = useSidebar() const filterType = useMemo(() => { return showItems !== undefined ? "show" @@ -75,7 +75,7 @@ export const ChildDocs = ({ ? Object.assign({}, currentItems) : undefined : { - default: [...(getActiveItem()?.children || [])], + default: [...(activeItem?.children || [])], } if (filterType === "all" || !targetItems) { return targetItems @@ -85,7 +85,7 @@ export const ChildDocs = ({ ...targetItems, default: filterItems(targetItems.default), } - }, [currentItems, type, getActiveItem, filterItems]) + }, [currentItems, type, activeItem, filterItems]) const filterNonInteractiveItems = ( items: SidebarItem[] | undefined diff --git a/www/packages/docs-ui/src/components/MainNav/Breadcrumb/index.tsx b/www/packages/docs-ui/src/components/MainNav/Breadcrumb/index.tsx index cccf1ad1edba4..d2947ddf5a687 100644 --- a/www/packages/docs-ui/src/components/MainNav/Breadcrumb/index.tsx +++ b/www/packages/docs-ui/src/components/MainNav/Breadcrumb/index.tsx @@ -7,7 +7,7 @@ import Link from "next/link" import { SidebarItemLink } from "types" export const MainNavBreadcrumbs = () => { - const { currentItems, getActiveItem } = useSidebar() + const { currentItems, activeItem } = useSidebar() const { activeItem: mainNavActiveItem, breadcrumbOptions: { showCategories }, @@ -63,7 +63,6 @@ export const MainNavBreadcrumbs = () => { ) } - const activeItem = getActiveItem() if (activeItem && !mainNavActiveItem?.path.endsWith(activeItem.path)) { if ( activeItem.parentItem && @@ -83,7 +82,7 @@ export const MainNavBreadcrumbs = () => { } return tempBreadcrumbItems - }, [currentItems, getActiveItem]) + }, [currentItems, activeItem]) return (
{ + return ( + + + + {children} + + + + ) +} diff --git a/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx b/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx index be4265978f5fe..780328c210aa6 100644 --- a/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx +++ b/www/packages/docs-ui/src/components/Sidebar/Item/Link/index.tsx @@ -76,6 +76,12 @@ export const SidebarItemLink = ({ newTopCalculator, ]) + useEffect(() => { + if (active && isMobile) { + setSidebarOpen(false) + } + }, [active, isMobile]) + const hasChildren = useMemo(() => { return !item.isChildSidebar && (item.children?.length || 0) > 0 }, [item.children]) @@ -108,14 +114,6 @@ export const SidebarItemLink = ({ "flex justify-between items-center gap-[6px]", className )} - scroll={true} - onClick={() => { - if (isMobile) { - setSidebarOpen(false) - } - }} - replace={!item.isPathHref} - shallow={!item.isPathHref} {...item.linkProps} > { const [items, setItems] = useState([]) const [showMenu, setShowMenu] = useState(false) - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const { items: headingItems, activeItemId } = useActiveOnScroll({}) const [maxHeight, setMaxHeight] = useState(0) const { scrollableElement } = useScrollController() diff --git a/www/packages/docs-ui/src/components/TypeList/Items/index.tsx b/www/packages/docs-ui/src/components/TypeList/Items/index.tsx index d5e128792deb9..33cd7d47c4dd8 100644 --- a/www/packages/docs-ui/src/components/TypeList/Items/index.tsx +++ b/www/packages/docs-ui/src/components/TypeList/Items/index.tsx @@ -42,7 +42,7 @@ const TypeListItem = ({ sectionTitle, referenceType = "method", }: TypeListItemProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const { config: { baseUrl, basePath }, diff --git a/www/packages/docs-ui/src/components/index.ts b/www/packages/docs-ui/src/components/index.ts index 35c582d5b074b..824c7d400a46f 100644 --- a/www/packages/docs-ui/src/components/index.ts +++ b/www/packages/docs-ui/src/components/index.ts @@ -53,6 +53,7 @@ export * from "./Notification/Item/Layout/Default" export * from "./Pagination" export * from "./Prerequisites" export * from "./Rating" +export * from "./RootProviders" export * from "./Search" export * from "./Search/EmptyQueryBoundary" export * from "./Search/Hits" diff --git a/www/packages/docs-ui/src/hooks/index.ts b/www/packages/docs-ui/src/hooks/index.ts index ba29e0e57bf2e..386d5632a88fb 100644 --- a/www/packages/docs-ui/src/hooks/index.ts +++ b/www/packages/docs-ui/src/hooks/index.ts @@ -5,7 +5,6 @@ export * from "./use-collapsible-code-lines" export * from "./use-copy" export * from "./use-current-learning-path" export * from "./use-is-external-link" -export * from "./use-is-browser" export * from "./use-keyboard-shortcut" export * from "./use-page-scroll-manager" export * from "./use-request-runner" diff --git a/www/packages/docs-ui/src/hooks/use-active-on-scroll/index.tsx b/www/packages/docs-ui/src/hooks/use-active-on-scroll/index.tsx index 4153dae6cc59b..f71ea7a8b8254 100644 --- a/www/packages/docs-ui/src/hooks/use-active-on-scroll/index.tsx +++ b/www/packages/docs-ui/src/hooks/use-active-on-scroll/index.tsx @@ -24,7 +24,7 @@ export const useActiveOnScroll = ({ const [items, setItems] = useState([]) const [activeItemId, setActiveItemId] = useState("") const { scrollableElement } = useScrollController() - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const root = useMemo(() => { if (!enable) { @@ -86,6 +86,8 @@ export const useActiveOnScroll = ({ return } const headings = getHeadingsInElm() + let selectedHeadingByHash: HTMLHeadingElement | undefined = undefined + const hash = location.hash.replace("#", "") let closestPositiveHeading: HTMLHeadingElement | undefined = undefined let closestNegativeHeading: HTMLHeadingElement | undefined = undefined let closestPositiveDistance = Infinity @@ -97,6 +99,9 @@ export const useActiveOnScroll = ({ : 0 headings?.forEach((heading) => { + if (heading.id === hash) { + selectedHeadingByHash = heading as HTMLHeadingElement + } const headingDistance = heading.getBoundingClientRect().top if (headingDistance > 0 && headingDistance < closestPositiveDistance) { @@ -126,6 +131,8 @@ export const useActiveOnScroll = ({ setActiveItemId( chosenClosest ? (chosenClosest as HTMLHeadingElement).id + : selectedHeadingByHash + ? (selectedHeadingByHash as HTMLHeadingElement).id : items.length ? useDefaultIfNoActive ? items[0].heading.id diff --git a/www/packages/docs-ui/src/hooks/use-click-outside/index.tsx b/www/packages/docs-ui/src/hooks/use-click-outside/index.tsx index 821c80b871a30..fbb0c60b8ec96 100644 --- a/www/packages/docs-ui/src/hooks/use-click-outside/index.tsx +++ b/www/packages/docs-ui/src/hooks/use-click-outside/index.tsx @@ -12,7 +12,7 @@ export const useClickOutside = ({ elmRef, onClickOutside, }: UseClickOutsideProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const checkClickOutside = useCallback( (e: MouseEvent) => { diff --git a/www/packages/docs-ui/src/hooks/use-is-browser/index.ts b/www/packages/docs-ui/src/hooks/use-is-browser/index.ts deleted file mode 100644 index 432a89fc3c93f..0000000000000 --- a/www/packages/docs-ui/src/hooks/use-is-browser/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" - -export const useIsBrowser = () => { - const [isBrowser, setIsBrowser] = useState(false) - - useEffect(() => { - setIsBrowser(typeof window !== "undefined") - }, []) - - return isBrowser -} diff --git a/www/packages/docs-ui/src/layouts/main-content.tsx b/www/packages/docs-ui/src/layouts/main-content.tsx index 4c930d4febc98..79f3abeb2831f 100644 --- a/www/packages/docs-ui/src/layouts/main-content.tsx +++ b/www/packages/docs-ui/src/layouts/main-content.tsx @@ -16,7 +16,7 @@ export const MainContentLayout = ({ mainWrapperClasses, showBanner = true, }: MainContentLayoutProps) => { - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const { desktopSidebarOpen } = useSidebar() useEffect(() => { diff --git a/www/packages/docs-ui/src/layouts/root.tsx b/www/packages/docs-ui/src/layouts/root.tsx index 18a523846b6ee..7dd7e22734660 100644 --- a/www/packages/docs-ui/src/layouts/root.tsx +++ b/www/packages/docs-ui/src/layouts/root.tsx @@ -1,6 +1,6 @@ import React from "react" import clsx from "clsx" -import { Sidebar, SidebarProps } from "@/components" +import { RootProviders, Sidebar, SidebarProps } from "@/components" import { MobileNavigation } from "../components/MobileNavigation" import { Toc } from "../components/Toc" import { MainContentLayout, MainContentLayoutProps } from "./main-content" @@ -36,14 +36,16 @@ export const RootLayout = ({ bodyClassName )} > - - - -
- - {showToc && } -
-
+ + + + +
+ + {showToc && } +
+
+
) diff --git a/www/packages/docs-ui/src/providers/BrowserProvider/index.tsx b/www/packages/docs-ui/src/providers/BrowserProvider/index.tsx new file mode 100644 index 0000000000000..bb3e5e9f929af --- /dev/null +++ b/www/packages/docs-ui/src/providers/BrowserProvider/index.tsx @@ -0,0 +1,41 @@ +"use client" + +import React, { useContext, useEffect, useState } from "react" + +type BrowserContextType = { + isBrowser: boolean +} + +const BrowserContext = React.createContext(null) + +type BrowserProviderProps = { + children: React.ReactNode +} + +export const BrowserProvider = ({ children }: BrowserProviderProps) => { + const [isBrowser, setIsBrowser] = useState(false) + + useEffect(() => { + setIsBrowser(typeof window !== "undefined") + }, []) + + return ( + + {children} + + ) +} + +export const useIsBrowser = () => { + const context = useContext(BrowserContext) + + if (!context) { + throw new Error("useIsBrowser must be used within a BrowserProvider") + } + + return context +} diff --git a/www/packages/docs-ui/src/providers/LearningPath/index.tsx b/www/packages/docs-ui/src/providers/LearningPath/index.tsx index 414ec6c96f974..a15c784548907 100644 --- a/www/packages/docs-ui/src/providers/LearningPath/index.tsx +++ b/www/packages/docs-ui/src/providers/LearningPath/index.tsx @@ -1,10 +1,9 @@ "use client" -import { useIsBrowser } from "@/hooks" import { getLearningPath } from "@/utils/learning-paths" import React, { createContext, useContext, useEffect, useState } from "react" import { LearningPathFinishType } from "@/components/LearningPath/Finish" -import { useAnalytics } from "docs-ui" +import { useAnalytics, useIsBrowser } from "docs-ui" import { usePathname, useRouter } from "next/navigation" export type LearningPathType = { @@ -54,7 +53,7 @@ export const LearningPathProvider: React.FC = ({ }) => { const [path, setPath] = useState(null) const [currentStep, setCurrentStep] = useState(-1) - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const pathname = usePathname() const router = useRouter() const { track } = useAnalytics() diff --git a/www/packages/docs-ui/src/providers/Sidebar/index.tsx b/www/packages/docs-ui/src/providers/Sidebar/index.tsx index e9df8df7ad4c0..835e3b2650bb9 100644 --- a/www/packages/docs-ui/src/providers/Sidebar/index.tsx +++ b/www/packages/docs-ui/src/providers/Sidebar/index.tsx @@ -6,13 +6,13 @@ import React, { useCallback, useContext, useEffect, + useMemo, useReducer, useRef, useState, } from "react" import { usePathname, useRouter } from "next/navigation" import { getScrolledTop } from "@/utils" -import { useIsBrowser } from "@/hooks" import { SidebarItemSections, SidebarItem, @@ -22,6 +22,7 @@ import { SidebarItemCategory, SidebarItemLinkWithParent, } from "types" +import { useIsBrowser } from "../BrowserProvider" export type CurrentItemsState = SidebarSectionItems & { previousSidebar?: CurrentItemsState @@ -35,7 +36,7 @@ export type SidebarContextType = { items: SidebarSectionItems currentItems: CurrentItemsState | undefined activePath: string | null - getActiveItem: () => SidebarItemLinkWithParent | undefined + activeItem: SidebarItemLinkWithParent | undefined setActivePath: (path: string | null) => void isLinkActive: (item: SidebarItem, checkChildren?: boolean) => boolean isChildrenActive: (item: SidebarItemCategory) => boolean @@ -86,6 +87,8 @@ export type ActionType = replacementItems: SidebarSectionItems } +type LinksMap = Map + const areItemsEqual = (itemA: SidebarItem, itemB: SidebarItem): boolean => { if (itemA.type === "separator" || itemB.type === "separator") { return false @@ -122,6 +125,32 @@ const findItem = ( return foundItem } +const getLinksMap = ( + items: SidebarItem[], + initMap?: LinksMap, + parentItem?: InteractiveSidebarItem +): LinksMap => { + const map: LinksMap = initMap || new Map() + + items.forEach((item) => { + if (item.type === "separator") { + return + } + + if (item.type === "link") { + map.set(item.path, { + ...item, + parentItem, + }) + } + if (item.children?.length) { + getLinksMap(item.children, map, item) + } + }) + + return map +} + export const reducer = (state: SidebarSectionItems, actionData: ActionType) => { if (actionData.type === "replace") { return actionData.replacementItems @@ -226,6 +255,20 @@ export const SidebarProvider = ({ CurrentItemsState | undefined >() const [activePath, setActivePath] = useState("") + const linksMap: LinksMap = useMemo(() => { + return new Map([ + ...getLinksMap(items.mobile), + ...getLinksMap(items.default), + ]) + }, [items]) + const findItemInSection = useCallback(findItem, []) + const activeItem = useMemo(() => { + if (activePath === null) { + return undefined + } + + return linksMap.get(activePath) + }, [activePath, linksMap]) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [sidebarTopHeight, setSidebarTopHeight] = useState(0) const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true) @@ -233,35 +276,20 @@ export const SidebarProvider = ({ const pathname = usePathname() const router = useRouter() - const isBrowser = useIsBrowser() + const { isBrowser } = useIsBrowser() const getResolvedScrollableElement = useCallback(() => { return scrollableElement || window }, [scrollableElement]) - const findItemInSection = useCallback(findItem, []) - const isItemLoaded = useCallback( (path: string) => { - const item = - findItemInSection(items.mobile, { path, type: "link" }) || - findItemInSection(items.default, { path, type: "link" }) + const item = linksMap.get(path) return item?.loaded || false }, - [items] + [items, linksMap] ) - const getActiveItem = useCallback(() => { - if (activePath === null) { - return undefined - } - - return ( - findItemInSection(items.mobile, { path: activePath, type: "link" }) || - findItemInSection(items.default, { path: activePath, type: "link" }) - ) - }, [activePath, items, findItemInSection]) - const addItems = (newItems: SidebarItem[], options?: ActionOptionsType) => { dispatch({ type: options?.parent ? "update" : "add", @@ -322,10 +350,12 @@ export const SidebarProvider = ({ if (!currentSidebar && item.children?.length) { const childSidebar = getCurrentSidebar(item.children) || - findItem(item.children, { - path: activePath || undefined, - type: "link", - }) + (activePath + ? findItem(item.children, { + path: activePath || undefined, + type: "link", + }) + : undefined) if (childSidebar) { currentSidebar = childSidebar.isChildSidebar ? childSidebar : item @@ -385,10 +415,16 @@ export const SidebarProvider = ({ const handleScroll = () => { if (getScrolledTop(resolvedScrollableElement) === 0) { - setActivePath("") - // can't use next router as it doesn't support - // changing url without scrolling - history.replaceState({}, "", location.pathname) + const firstItemPath = + items.default.length && items.default[0].type === "link" + ? items.default[0].path + : "" + setActivePath(firstItemPath) + if (firstItemPath) { + router.push(`#${firstItemPath}`, { + scroll: false, + }) + } } } @@ -540,7 +576,7 @@ export const SidebarProvider = ({ setMobileSidebarOpen, desktopSidebarOpen, setDesktopSidebarOpen, - getActiveItem, + activeItem, staticSidebarItems, disableActiveTransition, shouldHandleHashChange, diff --git a/www/packages/docs-ui/src/providers/index.ts b/www/packages/docs-ui/src/providers/index.ts index 0c906ef4caaca..0303e5b16f1f6 100644 --- a/www/packages/docs-ui/src/providers/index.ts +++ b/www/packages/docs-ui/src/providers/index.ts @@ -1,5 +1,6 @@ export * from "./AiAssistant" export * from "./Analytics" +export * from "./BrowserProvider" export * from "./ColorMode" export * from "./LearningPath" export * from "./MainNav" diff --git a/www/yarn.lock b/www/yarn.lock index 97a39957500b8..e1f9094ff8067 100644 --- a/www/yarn.lock +++ b/www/yarn.lock @@ -6304,17 +6304,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001578, caniuse-lite@npm:^1.0.30001587": - version: 1.0.30001589 - resolution: "caniuse-lite@npm:1.0.30001589" - checksum: 20debfb949413f603011bc7dacaf050010778bc4f8632c86fafd1bd0c43180c95ae7c31f6c82348f6309e5e221934e327c3607a216e3f09640284acf78cd6d4d - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001579": - version: 1.0.30001597 - resolution: "caniuse-lite@npm:1.0.30001597" - checksum: 32dc315ffafacc8167286c95b05f41b3ce2818314ea913ffed6ceb7b58c64c38365ec250114d1ecceac34f1c77e5af089479e54b160c4a89b88fd25a98851b78 +"caniuse-lite@npm:^1.0.30001464, caniuse-lite@npm:^1.0.30001578, caniuse-lite@npm:^1.0.30001579, caniuse-lite@npm:^1.0.30001587": + version: 1.0.30001666 + resolution: "caniuse-lite@npm:1.0.30001666" + checksum: 2d49e9be676233c24717f12aad3d01b3e5f902b457fe1deefaa8d82e64786788a8f79381ae437c61b50e15c9aea8aeb59871b1d54cb4c28b9190d53d292e2339 languageName: node linkType: hard