diff --git a/packages/elements-core/src/components/HashRouterSync/index.tsx b/packages/elements-core/src/components/HashRouterSync/index.tsx new file mode 100644 index 000000000..04f834730 --- /dev/null +++ b/packages/elements-core/src/components/HashRouterSync/index.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +/** + * HashRouterSync ensures React Router v6's HashRouter properly responds to hash changes + * when used in web component contexts (like Custom Elements with Shadow DOM). + * + * The issue: React Router v6's HashRouter doesn't always detect hash changes when: + * - Embedded in a web component + * - Running inside another SPA framework (e.g., VitePress) + * - Events don't properly bubble through Shadow DOM boundaries + * + * This component listens for hash changes and forces React Router to navigate, + * ensuring content updates when users click navigation links. + */ +export const HashRouterSync = (): null => { + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + // Track the current hash to detect changes + let currentHash = window.location.hash; + + const syncHashWithRouter = () => { + const newHash = window.location.hash; + + // Only navigate if the hash actually changed and doesn't match React Router's current location + if (newHash !== currentHash) { + currentHash = newHash; + + // Extract the path from the hash (e.g., "#/path" -> "/path") + const path = newHash.slice(1) || '/'; + + // Only navigate if React Router isn't already at this path + if (location.pathname + location.search + location.hash !== path) { + navigate(path, { replace: true }); + } + } + }; + + // Listen for hash changes from the browser + window.addEventListener('hashchange', syncHashWithRouter); + + // Also listen for popstate events (browser back/forward) + window.addEventListener('popstate', syncHashWithRouter); + + // Sync on mount to handle direct navigation to hashed URLs + syncHashWithRouter(); + + return () => { + window.removeEventListener('hashchange', syncHashWithRouter); + window.removeEventListener('popstate', syncHashWithRouter); + }; + }, [navigate, location]); + + return null; +}; diff --git a/packages/elements-core/src/hoc/withRouter.tsx b/packages/elements-core/src/hoc/withRouter.tsx index 7a8509a61..cd2cd0fcc 100644 --- a/packages/elements-core/src/hoc/withRouter.tsx +++ b/packages/elements-core/src/hoc/withRouter.tsx @@ -2,6 +2,7 @@ import { DefaultComponentMapping } from '@stoplight/markdown-viewer'; import * as React from 'react'; import { Route, Routes, useInRouterContext } from 'react-router-dom'; +import { HashRouterSync } from '../components/HashRouterSync'; import { LinkHeading } from '../components/LinkHeading'; import { MarkdownComponentsProvider } from '../components/MarkdownViewer/CustomComponents/Provider'; import { ReactRouterMarkdownLink } from '../components/MarkdownViewer/CustomComponents/ReactRouterLink'; @@ -18,7 +19,7 @@ const components: Partial = { h4: ({ color, ...props }) => , }; -const InternalRoutes = ({ children }: { children: React.ReactNode }): JSX.Element => { +const InternalRoutes = ({ children, routerType }: { children: React.ReactNode; routerType: string }): JSX.Element => { return ( + {/* Sync hash changes with React Router when using HashRouter */} + {routerType === 'hash' && } {children} } @@ -48,7 +51,7 @@ export function withRouter

( return ( - + @@ -58,7 +61,7 @@ export function withRouter

( return ( - + diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index 3e7dd2e56..02e9ba0c3 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -1,6 +1,7 @@ export { Docs, DocsProps, ExtensionAddonRenderer, ExtensionRowProps, ParsedDocs } from './components/Docs'; export { DeprecatedBadge } from './components/Docs/HttpOperation/Badges'; export { ExportButton, ExportButtonProps } from './components/Docs/HttpService/ExportButton'; +export { HashRouterSync } from './components/HashRouterSync'; export { ResponsiveSidebarLayout } from './components/Layout/ResponsiveSidebarLayout'; export { SidebarLayout } from './components/Layout/SidebarLayout'; export { LinkHeading } from './components/LinkHeading';