Skip to content

Commit 365efef

Browse files
Drew ZhaoYinghao Zhao
authored andcommitted
fix: HashRouter navigation not working in web component contexts
React Router v6's HashRouter doesn't properly respond to hash changes when used in web component contexts (Custom Elements with Shadow DOM) or when embedded in other SPA frameworks like VitePress. This commit adds a HashRouterSync component that: - Listens for hash change and popstate events - Forces React Router to navigate when the browser hash changes - Ensures content updates when users click navigation links - Only activates when router type is 'hash' The fix is transparent to users and requires no API changes. Fixes navigation issues reported with Elements v9.0.12 after the React Router v5 to v6 upgrade (commit 8520585). Technical details: - Created HashRouterSync component using useNavigate and useLocation hooks - Integrated into withRouter HOC's InternalRoutes - Syncs on mount, hashchange, and popstate events - Prevents infinite loops by tracking current hash state Testing: This fix has been validated in VitePress environments with hash-based navigation, confirming that: - URL updates correctly on navigation - Content refreshes when clicking navigation links - Browser back/forward buttons work properly - No performance impact or console errors
1 parent 47921de commit 365efef

3 files changed

Lines changed: 71 additions & 3 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useEffect } from 'react';
2+
import { useNavigate, useLocation } from 'react-router-dom';
3+
4+
/**
5+
* HashRouterSync ensures React Router v6's HashRouter properly responds to hash changes
6+
* when used in web component contexts (like Custom Elements with Shadow DOM).
7+
*
8+
* The issue: React Router v6's HashRouter doesn't always detect hash changes when:
9+
* - Embedded in a web component
10+
* - Running inside another SPA framework (e.g., VitePress)
11+
* - Events don't properly bubble through Shadow DOM boundaries
12+
*
13+
* This component listens for hash changes and forces React Router to navigate,
14+
* ensuring content updates when users click navigation links.
15+
*/
16+
export const HashRouterSync = (): null => {
17+
const navigate = useNavigate();
18+
const location = useLocation();
19+
20+
useEffect(() => {
21+
// Track the current hash to detect changes
22+
let currentHash = window.location.hash;
23+
24+
const syncHashWithRouter = () => {
25+
const newHash = window.location.hash;
26+
27+
// Only navigate if the hash actually changed and doesn't match React Router's current location
28+
if (newHash !== currentHash) {
29+
currentHash = newHash;
30+
31+
// Extract the path from the hash (e.g., "#/path" -> "/path")
32+
const path = newHash.slice(1) || '/';
33+
34+
// Only navigate if React Router isn't already at this path
35+
if (location.pathname + location.search + location.hash !== path) {
36+
navigate(path, { replace: true });
37+
}
38+
}
39+
};
40+
41+
// Listen for hash changes from the browser
42+
window.addEventListener('hashchange', syncHashWithRouter);
43+
44+
// Also listen for popstate events (browser back/forward)
45+
window.addEventListener('popstate', syncHashWithRouter);
46+
47+
// Sync on mount to handle direct navigation to hashed URLs
48+
syncHashWithRouter();
49+
50+
return () => {
51+
window.removeEventListener('hashchange', syncHashWithRouter);
52+
window.removeEventListener('popstate', syncHashWithRouter);
53+
};
54+
}, [navigate, location]);
55+
56+
return null;
57+
};
58+

packages/elements-core/src/hoc/withRouter.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { DefaultComponentMapping } from '@stoplight/markdown-viewer';
22
import * as React from 'react';
33
import { Route, Routes, useInRouterContext } from 'react-router-dom';
44

5+
import { HashRouterSync } from '../components/HashRouterSync';
56
import { LinkHeading } from '../components/LinkHeading';
67
import { MarkdownComponentsProvider } from '../components/MarkdownViewer/CustomComponents/Provider';
78
import { ReactRouterMarkdownLink } from '../components/MarkdownViewer/CustomComponents/ReactRouterLink';
@@ -18,14 +19,22 @@ const components: Partial<DefaultComponentMapping> = {
1819
h4: ({ color, ...props }) => <LinkHeading size={4} {...props} />,
1920
};
2021

21-
const InternalRoutes = ({ children }: { children: React.ReactNode }): JSX.Element => {
22+
const InternalRoutes = ({
23+
children,
24+
routerType
25+
}: {
26+
children: React.ReactNode;
27+
routerType: string;
28+
}): JSX.Element => {
2229
return (
2330
<Routes>
2431
<Route
2532
path="/*"
2633
element={
2734
<MarkdownComponentsProvider value={components}>
2835
<ScrollToHashElement />
36+
{/* Sync hash changes with React Router when using HashRouter */}
37+
{routerType === 'hash' && <HashRouterSync />}
2938
{children}
3039
</MarkdownComponentsProvider>
3140
}
@@ -48,7 +57,7 @@ export function withRouter<P extends RoutingProps>(
4857
return (
4958
<RouterTypeContext.Provider value={routerType}>
5059
<Router {...routerProps} key={basePath}>
51-
<InternalRoutes>
60+
<InternalRoutes routerType={routerType}>
5261
<WrappedComponent {...props} outerRouter={false} />
5362
</InternalRoutes>
5463
</Router>
@@ -58,7 +67,7 @@ export function withRouter<P extends RoutingProps>(
5867

5968
return (
6069
<RouterTypeContext.Provider value={routerType}>
61-
<InternalRoutes>
70+
<InternalRoutes routerType={routerType}>
6271
<WrappedComponent {...props} outerRouter />
6372
</InternalRoutes>
6473
</RouterTypeContext.Provider>

packages/elements-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export {
1212
} from './components/MarkdownViewer/CustomComponents/Provider';
1313
export { ReactRouterMarkdownLink } from './components/MarkdownViewer/CustomComponents/ReactRouterLink';
1414
export { ScrollToHashElement } from './components/MarkdownViewer/CustomComponents/ScrollToHashElement';
15+
export { HashRouterSync } from './components/HashRouterSync';
1516
export { NonIdealState } from './components/NonIdealState';
1617
export { PoweredByLink } from './components/PoweredByLink';
1718
export { TableOfContents } from './components/TableOfContents';

0 commit comments

Comments
 (0)