HashRouter navigation doesn't update content in web component contexts (v9.0+ regression)
Context
This bug prevents users from navigating API documentation when using Stoplight Elements v9.x with router="hash" in web component contexts (particularly when embedded in other SPA frameworks like VitePress). When clicking navigation links in the table of contents, the URL hash updates correctly (e.g., from #/ to #/operations/chatCompletions), but the main content area does not refresh to display the selected endpoint or schema, rendering the documentation unusable.
This is a regression introduced in v9.0.0 when Stoplight Elements upgraded from React Router v5 to v6 (commit 85205855, December 2024). Version 8.5.2 and earlier work correctly.
Root Cause
React Router v6's HashRouter component does not properly respond to hash changes when running inside web components (Custom Elements with Shadow DOM) or when embedded in other SPA frameworks. Specifically:
-
Event Propagation Issue: Hash change events (
hashchange,popstate) from the browser don't properly reach React Router v6 when it's encapsulated within a web component's shadow DOM - Web Component Boundary: React Router v6's internal navigation listeners don't account for the event bubbling differences in shadow DOM contexts
- SPA Framework Conflicts: When Stoplight Elements (which has its own router) is embedded in another SPA like VitePress (which also has its own router), React Router v6's HashRouter doesn't detect external hash changes
The upgrade from React Router v5 to v6 changed the internal event handling mechanism, and this new implementation doesn't work correctly in web component contexts where event bubbling behaves differently.
Note: This is NOT related to the React Router v7 future flag warnings that appear in the console. Those warnings are informational and do not cause this navigation bug.
Current Behavior
When using Stoplight Elements v9.0+ with router="hash":
-
URL updates correctly: Clicking a navigation link in the sidebar/table of contents updates the browser's URL hash (e.g.,
/api#/operations/chatCompletions) - Content does NOT refresh: The main content area remains showing the previous section instead of updating to display the selected operation, schema, or endpoint
-
Browser events fire: Using browser DevTools confirms that
hashchangeevents are firing correctly - Navigation appears broken: Users cannot browse the API documentation by clicking links, making the documentation effectively unusable
-
Direct navigation works: Manually typing or bookmarking a URL with a hash (e.g.,
/api#/operations/chatCompletions) and navigating to it works on page load - Back/forward buttons don't work: Browser history navigation also fails to update content
Expected Behavior
Clicking navigation links within the Stoplight Elements component should update both the URL hash and the displayed content to reflect the selected API endpoint or section. This includes:
- Clicking table of contents items should navigate to the corresponding operation/schema/model
- Content area should immediately display the selected section
- Browser back/forward buttons should work correctly
- Deep-linking with hashes should work on initial page load and subsequent navigation
- All navigation functionality should work as it did in v8.5.2
Possible Workaround/Solution
Temporary Workarounds
-
Downgrade to v8.5.2: Revert to Stoplight Elements v8.5.2, which uses React Router v5 and works correctly with hash-based navigation
const stoplightVersion = '8.5.2'; -
Use history router: Switch from
router="hash"torouter="history", though this may require server-side configuration to handle routes properly -
Runtime patch (recommended): Add JavaScript that manually syncs hash changes with the React Router instance. See implementation example in reproduction steps below.
Permanent Fix
A proper fix requires modifying Stoplight Elements to sync browser hash changes with React Router v6. The recommended approach is to:
- Create a
HashRouterSynccomponent that listens forhashchangeandpopstateevents - Use React Router's
useNavigate()hook to programmatically navigate when the browser hash changes - Integrate this component into the router wrapper so it only activates when
router="hash"
This ensures React Router v6 stays synchronized with browser hash changes regardless of the embedding context.
Steps to Reproduce
Minimal Reproduction
- Create an HTML page with Stoplight Elements v9.0+ web components
- Configure
<elements-api>withrouter="hash"and any valid OpenAPI spec - Load the page in a browser
- Click any navigation link in the table of contents (e.g., an operation or schema)
-
Observe: URL hash updates (e.g.,
page.html#/operations/someOperation) but content remains unchanged - Expected: Content should update to show the selected operation/schema
VitePress Integration (Demonstrates the Issue)
The following VitePress markdown file demonstrates the bug:
---
title: "API Reference"
description: "API documentation powered by Stoplight Elements"
layout: page
sidebar: false
pageClass: ElementApi
---
<script setup>
import { onMounted } from 'vue';
import { withBase } from 'vitepress';
const stoplightVersion = '9.0.12'; // Bug affects v9.0.0 through v9.0.12
onMounted(() => {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
// Load Stoplight Elements web component
if (!document.querySelector(`script[src="https://fastly.jsdelivr.net/npm/@stoplight/elements@${stoplightVersion}/web-components.min.js"]`)) {
const script = document.createElement('script');
script.src = `https://fastly.jsdelivr.net/npm/@stoplight/elements@${stoplightVersion}/web-components.min.js`;
script.defer = false;
document.body.appendChild(script);
}
if (!document.querySelector(`link[href="https://fastly.jsdelivr.net/npm/@stoplight/elements@${stoplightVersion}/styles.min.css"]`)) {
const styleLink = document.createElement('link');
styleLink.rel = 'stylesheet';
styleLink.href = `https://fastly.jsdelivr.net/npm/@stoplight/elements@${stoplightVersion}/styles.min.css`;
document.head.appendChild(styleLink);
}
}
});
const apiUrl = withBase('/openapi/spec.yaml')
</script>
<ClientOnly>
<elements-api
:apiDescriptionUrl="apiUrl"
router="hash"
hideInternal="true"
layout="responsive"
/>
</ClientOnly>
Verification Steps
To verify the bug:
- Open browser DevTools Console tab
- Monitor for hash changes by running:
window.addEventListener('hashchange', () => console.log('Hash changed to:', window.location.hash)) - Click a navigation link in Stoplight Elements
- You'll see: Console logs the hash change, but the content doesn't update
-
Compare with v8.5.2: Change
stoplightVersionto'8.5.2'and reload - navigation works correctly
Additional Context
- The bug occurs in any web component context, not just VitePress
- Plain HTML files with
<elements-api>also exhibit this behavior - The issue is specific to
router="hash"-router="history"may work differently - Browser back/forward buttons also fail to update content
- React Router v7 warnings in console are unrelated to this bug
Environment
- Version affected: Stoplight Elements v9.0.0 and later (tested through v9.0.12)
- Version working: Stoplight Elements v8.5.2 and earlier
- Browser tested: Google Chrome 135.0.7049.42, Firefox, Safari (all affected)
- Operating System: macOS 15.3 (24D60), Windows 11, Ubuntu 22.04 (all affected)
- Framework: VitePress 1.x, plain HTML, Next.js (all affected)
- Breaking change introduced: Commit 85205855 (React Router v5 → v6 upgrade, December 2024)
Additional Information
Related Code
The issue originates from how withRouter HOC wraps components with React Router v6's HashRouter. The router doesn't detect hash changes from the browser when running in web component contexts.
Relevant file: packages/elements-core/src/hoc/withRouter.tsx
Proposed Fix
Add a synchronization component that bridges browser hash changes with React Router:
// New component: packages/elements-core/src/components/HashRouterSync/index.tsx
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
export const HashRouterSync = (): null => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const syncHashWithRouter = () => {
const newHash = window.location.hash;
const path = newHash.slice(1) || '/';
if (location.pathname + location.search + location.hash !== path) {
navigate(path, { replace: true });
}
};
window.addEventListener('hashchange', syncHashWithRouter);
window.addEventListener('popstate', syncHashWithRouter);
syncHashWithRouter(); // Sync on mount
return () => {
window.removeEventListener('hashchange', syncHashWithRouter);
window.removeEventListener('popstate', syncHashWithRouter);
};
}, [navigate, location]);
return null;
};
Then integrate it into withRouter.tsx to conditionally render when routerType === 'hash'.
Impact
This bug makes Stoplight Elements v9.x unusable for hash-based navigation in web component contexts, which is a common deployment pattern for embedded API documentation. Users are forced to either:
- Stay on v8.5.2 (missing newer features and security updates)
- Switch to history router (requires server configuration)
- Implement custom workarounds (adds maintenance burden)
A fix in the core library would benefit all users deploying Elements as web components.