Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/elements-core/src/components/HashRouterSync/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
9 changes: 6 additions & 3 deletions packages/elements-core/src/hoc/withRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,14 +19,16 @@ const components: Partial<DefaultComponentMapping> = {
h4: ({ color, ...props }) => <LinkHeading size={4} {...props} />,
};

const InternalRoutes = ({ children }: { children: React.ReactNode }): JSX.Element => {
const InternalRoutes = ({ children, routerType }: { children: React.ReactNode; routerType: string }): JSX.Element => {
return (
<Routes>
<Route
path="/*"
element={
<MarkdownComponentsProvider value={components}>
<ScrollToHashElement />
{/* Sync hash changes with React Router when using HashRouter */}
{routerType === 'hash' && <HashRouterSync />}
{children}
</MarkdownComponentsProvider>
}
Expand All @@ -48,7 +51,7 @@ export function withRouter<P extends RoutingProps>(
return (
<RouterTypeContext.Provider value={routerType}>
<Router {...routerProps} key={basePath}>
<InternalRoutes>
<InternalRoutes routerType={routerType}>
<WrappedComponent {...props} outerRouter={false} />
</InternalRoutes>
</Router>
Expand All @@ -58,7 +61,7 @@ export function withRouter<P extends RoutingProps>(

return (
<RouterTypeContext.Provider value={routerType}>
<InternalRoutes>
<InternalRoutes routerType={routerType}>
<WrappedComponent {...props} outerRouter />
</InternalRoutes>
</RouterTypeContext.Provider>
Expand Down
1 change: 1 addition & 0 deletions packages/elements-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down