From 365efef60a8c3b647a1fbc8a4d190a0c1e8ca11d Mon Sep 17 00:00:00 2001 From: Drew Zhao Date: Thu, 20 Nov 2025 00:13:33 +0800 Subject: [PATCH 1/2] 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 85205855). 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 --- .../src/components/HashRouterSync/index.tsx | 58 +++++++++++++++++++ packages/elements-core/src/hoc/withRouter.tsx | 15 ++++- packages/elements-core/src/index.ts | 1 + 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 packages/elements-core/src/components/HashRouterSync/index.tsx 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..96b2a77d8 --- /dev/null +++ b/packages/elements-core/src/components/HashRouterSync/index.tsx @@ -0,0 +1,58 @@ +import { useEffect } from 'react'; +import { useNavigate, useLocation } 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..df7602b8d 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,13 @@ 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 +57,7 @@ export function withRouter

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

( return ( - + diff --git a/packages/elements-core/src/index.ts b/packages/elements-core/src/index.ts index 3e7dd2e56..9ec39b82f 100644 --- a/packages/elements-core/src/index.ts +++ b/packages/elements-core/src/index.ts @@ -12,6 +12,7 @@ export { } from './components/MarkdownViewer/CustomComponents/Provider'; export { ReactRouterMarkdownLink } from './components/MarkdownViewer/CustomComponents/ReactRouterLink'; export { ScrollToHashElement } from './components/MarkdownViewer/CustomComponents/ScrollToHashElement'; +export { HashRouterSync } from './components/HashRouterSync'; export { NonIdealState } from './components/NonIdealState'; export { PoweredByLink } from './components/PoweredByLink'; export { TableOfContents } from './components/TableOfContents'; From 9c106487dbf6ba4d63e757d5f6273e2d41f9fac5 Mon Sep 17 00:00:00 2001 From: Yinghao Zhao Date: Wed, 7 Jan 2026 21:55:29 +0800 Subject: [PATCH 2/2] fix: resolve ESLint formatting and import/export sorting errors --- .../src/components/HashRouterSync/index.tsx | 13 ++++++------- packages/elements-core/src/hoc/withRouter.tsx | 8 +------- packages/elements-core/src/index.ts | 2 +- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/elements-core/src/components/HashRouterSync/index.tsx b/packages/elements-core/src/components/HashRouterSync/index.tsx index 96b2a77d8..04f834730 100644 --- a/packages/elements-core/src/components/HashRouterSync/index.tsx +++ b/packages/elements-core/src/components/HashRouterSync/index.tsx @@ -1,15 +1,15 @@ import { useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +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. */ @@ -23,14 +23,14 @@ export const HashRouterSync = (): null => { 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 }); @@ -55,4 +55,3 @@ export const HashRouterSync = (): null => { return null; }; - diff --git a/packages/elements-core/src/hoc/withRouter.tsx b/packages/elements-core/src/hoc/withRouter.tsx index df7602b8d..cd2cd0fcc 100644 --- a/packages/elements-core/src/hoc/withRouter.tsx +++ b/packages/elements-core/src/hoc/withRouter.tsx @@ -19,13 +19,7 @@ const components: Partial = { h4: ({ color, ...props }) => , }; -const InternalRoutes = ({ - children, - routerType -}: { - children: React.ReactNode; - routerType: string; -}): JSX.Element => { +const InternalRoutes = ({ children, routerType }: { children: React.ReactNode; routerType: string }): JSX.Element => { return (