elements icon indicating copy to clipboard operation
elements copied to clipboard

HashRouter navigation doesn't update content in web component contexts (v9.0+ regression)

Open drewzhao opened this issue 9 months ago • 0 comments

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:

  1. 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
  2. Web Component Boundary: React Router v6's internal navigation listeners don't account for the event bubbling differences in shadow DOM contexts
  3. 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":

  1. URL updates correctly: Clicking a navigation link in the sidebar/table of contents updates the browser's URL hash (e.g., /api#/operations/chatCompletions)
  2. Content does NOT refresh: The main content area remains showing the previous section instead of updating to display the selected operation, schema, or endpoint
  3. Browser events fire: Using browser DevTools confirms that hashchange events are firing correctly
  4. Navigation appears broken: Users cannot browse the API documentation by clicking links, making the documentation effectively unusable
  5. 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
  6. 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

  1. 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';
    
  2. Use history router: Switch from router="hash" to router="history", though this may require server-side configuration to handle routes properly

  3. 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:

  1. Create a HashRouterSync component that listens for hashchange and popstate events
  2. Use React Router's useNavigate() hook to programmatically navigate when the browser hash changes
  3. 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

  1. Create an HTML page with Stoplight Elements v9.0+ web components
  2. Configure <elements-api> with router="hash" and any valid OpenAPI spec
  3. Load the page in a browser
  4. Click any navigation link in the table of contents (e.g., an operation or schema)
  5. Observe: URL hash updates (e.g., page.html#/operations/someOperation) but content remains unchanged
  6. 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:

  1. Open browser DevTools Console tab
  2. Monitor for hash changes by running: window.addEventListener('hashchange', () => console.log('Hash changed to:', window.location.hash))
  3. Click a navigation link in Stoplight Elements
  4. You'll see: Console logs the hash change, but the content doesn't update
  5. Compare with v8.5.2: Change stoplightVersion to '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.

drewzhao avatar Apr 18 '25 06:04 drewzhao