-
Notifications
You must be signed in to change notification settings - Fork 57
feat(web): implement dynamic Preact re-hydration in ClientLink for SPA navigation #948
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| /** | ||
| * ClientLink – zero-reload navigation for doc-kit static sites. | ||
| * | ||
| * How it works: | ||
| * 1. Intercepts clicks on internal `<a>` links (via the component AND | ||
| * a global event-delegation listener so links inside swapped DOM | ||
| * keep working). | ||
| * 2. Fetches the target page's HTML in the background. | ||
| * 3. Replaces `#root` innerHTML with the new page's `#root` content, | ||
| * preserving the already-loaded CSS / JS assets in `<head>`. | ||
| * 4. Saves & restores the sidebar (`<aside>`) scroll position in a | ||
| * plain JS variable – no sessionStorage / localStorage needed. | ||
| * 5. Handles browser Back / Forward buttons via `popstate`. | ||
| * | ||
| * @module ClientLink | ||
| */ | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // State kept in module scope (survives DOM replacements, no storage needed) | ||
| // --------------------------------------------------------------------------- | ||
| let sidebarScrollTop = 0; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Returns true when `href` points to an internal page that can be navigated | ||
| * client-side (same-origin, not a hash-only link, not a special protocol). | ||
| * | ||
| * @param {string | undefined | null} href | ||
| * @returns {boolean} | ||
| */ | ||
| const isInternalLink = href => { | ||
| if (!href) { | ||
| return false; | ||
| } | ||
| if (/^(https?:|mailto:|tel:|#)/.test(href)) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }; | ||
|
|
||
| /** | ||
| * Returns true when the click event carries modifier keys that signal | ||
| * "open in new tab / window" – we should let the browser handle those. | ||
| */ | ||
| const hasModifier = e => | ||
| e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0; | ||
|
|
||
| /** | ||
| * Re-import and execute the new page's entrypoint module script after a DOM swap. | ||
| * By appending a timestamp query parameter, we bypass the browser's ES module | ||
| * evaluation cache, ensuring Preact runs `hydrate()` on the freshly injected `#root`. | ||
| * This natively re-attaches all synthetic event listeners (such as CodeBox copy | ||
| * buttons or CodeTabs switchers) without requiring browser storage or DOM hacks. | ||
| */ | ||
| function reinitPageFeatures(newDoc) { | ||
| const newScript = newDoc.querySelector('body script[type="module"][src]'); | ||
| if (newScript && newScript.getAttribute('src')) { | ||
| const scriptUrl = new URL( | ||
| newScript.getAttribute('src'), | ||
| window.location.origin | ||
| ); | ||
| // Bypass module evaluation cache so Preact re-hydrates on every navigation | ||
| scriptUrl.searchParams.set('t', Date.now()); | ||
| import(/* @vite-ignore */ scriptUrl.toString()).catch(() => {}); | ||
| } | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Core navigation function | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Perform client-side navigation to `url` without a full page reload. | ||
| * | ||
| * @param {string} url | ||
| * @param {boolean} [isPopState=false] – true when triggered by Back/Forward | ||
| */ | ||
| export const navigate = async (url, isPopState = false) => { | ||
| // 1. Save sidebar scroll position BEFORE touching the DOM | ||
| const sidebar = document.querySelector('aside'); | ||
| if (sidebar) { | ||
| sidebarScrollTop = sidebar.scrollTop; | ||
| } | ||
|
|
||
| // 2. Push browser history (skip for popstate – browser already did it) | ||
| if (!isPopState) { | ||
| window.history.pushState({}, '', url); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pushState runs outside error handlingMedium Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit b091c5f. Configure here. |
||
|
|
||
| try { | ||
| const response = await fetch(url); | ||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
|
|
||
| const html = await response.text(); | ||
| const newDoc = new DOMParser().parseFromString(html, 'text/html'); | ||
|
|
||
| const currentRoot = document.getElementById('root'); | ||
| const newRoot = newDoc.getElementById('root'); | ||
|
|
||
| if (currentRoot && newRoot) { | ||
| // 3. Swap the entire #root – all CSS module classes are preserved | ||
| // because they come straight from the server-rendered HTML and | ||
| // the shared styles.css is already loaded in <head>. | ||
| const updateDOM = () => { | ||
| currentRoot.innerHTML = newRoot.innerHTML; | ||
| document.title = newDoc.title; | ||
|
|
||
| // 4. Restore sidebar scroll position | ||
| const newSidebar = document.querySelector('aside'); | ||
| if (newSidebar) { | ||
| newSidebar.scrollTop = sidebarScrollTop; | ||
| } | ||
|
|
||
| // 5. Re-import the new page's module script to trigger Preact hydration | ||
| // on the newly swapped DOM nodes (CodeBox copy buttons, tabs, etc.). | ||
| reinitPageFeatures(newDoc); | ||
|
|
||
| // 6. Scroll to hash target if URL contains a hash, otherwise to top | ||
| const hash = new URL(url, window.location.origin).hash; | ||
| if (hash) { | ||
| const target = document.getElementById(hash.slice(1)); | ||
| if (target) { | ||
| target.scrollIntoView({ behavior: 'smooth' }); | ||
| } | ||
| } else { | ||
| window.scrollTo({ top: 0, behavior: 'instant' }); | ||
| } | ||
| }; | ||
|
|
||
| // Use View Transitions API for a smooth crossfade when available | ||
| if (document.startViewTransition) { | ||
| document.startViewTransition(updateDOM); | ||
| } else { | ||
| updateDOM(); | ||
| } | ||
| } else { | ||
| // Fallback: full page navigation | ||
| window.location.href = url; | ||
| } | ||
| } catch { | ||
| window.location.href = url; | ||
| } | ||
| }; | ||
|
|
||
| // Global event delegation (attached once, survives any DOM replacement) | ||
| if (typeof window !== 'undefined' && !window.__dockit_client_nav) { | ||
| window.__dockit_client_nav = true; | ||
|
|
||
| // Handle clicks on ANY <a> inside the page – even ones injected via | ||
| // innerHTML replacement – so navigation keeps working after a swap. | ||
| document.addEventListener('click', e => { | ||
| const anchor = e.target.closest('a[href]'); | ||
| if (!anchor) { | ||
| return; | ||
| } | ||
|
|
||
| const href = anchor.getAttribute('href'); | ||
| if (hasModifier(e)) { | ||
| return; | ||
| } | ||
|
|
||
| // ====== PICKER-HEADER LINKS ====== | ||
| // .picker-header > a toggles legacy dropdown menus. | ||
| // Never intercept these – let the attached click listener handle them. | ||
| if (anchor.closest('.picker-header')) { | ||
| return; | ||
| } | ||
|
|
||
| // ====== HASH-ONLY LINKS ====== | ||
| // Handle #heading-id links manually because native hash navigation can | ||
| // break after innerHTML swap of #root (the element may have moved). | ||
| if (href && href.startsWith('#')) { | ||
| const target = document.getElementById(href.slice(1)); | ||
| if (target) { | ||
| e.preventDefault(); | ||
| target.scrollIntoView({ behavior: 'smooth' }); | ||
| window.history.pushState({}, '', href); | ||
| } | ||
| return; | ||
| } | ||
|
|
||
| if (!isInternalLink(href)) { | ||
| return; | ||
| } | ||
|
|
||
| e.preventDefault(); | ||
| navigate(href); | ||
| }); | ||
|
|
||
| // Handle browser Back / Forward buttons | ||
| window.addEventListener('popstate', () => { | ||
| navigate(window.location.href, true); | ||
| }); | ||
| } | ||
|
|
||
| export default function ClientLink({ children, ...props }) { | ||
| return <a {...props}>{children}</a>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import SideBar from '@node-core/ui-components/Containers/Sidebar'; | |
|
|
||
| import styles from './index.module.css'; | ||
| import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs'; | ||
| import ClientLink, { navigate } from '../ClientLink'; | ||
|
|
||
| import { project, version, versions, pages } from '#theme/config'; | ||
|
|
||
|
|
@@ -17,7 +18,7 @@ const getMajorVersion = v => parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10); | |
| * Redirect to a URL | ||
| * @param {string} url URL | ||
| */ | ||
| const redirect = url => (window.location.href = url); | ||
| const redirect = url => navigate(url); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Version switch uses SPA navigateHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit b091c5f. Configure here. |
||
|
|
||
| /** | ||
| * Sidebar component for MDX documentation with version selection and page navigation | ||
|
|
@@ -49,7 +50,7 @@ export default ({ metadata }) => { | |
| pathname={`${metadata.basename}.html`} | ||
| groups={[{ groupName: 'API Documentation', items }]} | ||
| onSelect={redirect} | ||
| as={props => <a {...props} rel="prefetch" />} | ||
| as={ClientLink} | ||
| title="Navigation" | ||
| > | ||
| <div> | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script URL resolved against origin
High Severity
reinitPageFeaturesbuilds the entrypoint URL withwindow.location.originas the base. Relative scriptsrcvalues such as../page.jstherefore resolve from the domain root instead of the current page path, so the wrong module is requested (or a 404) whenever the site is not hosted at/. Preact never re-hydrates and interactive controls stop working after SPA navigation.Reviewed by Cursor Bugbot for commit b091c5f. Configure here.