2026-05-17 21:37:42 -05:00
|
|
|
import { useEffect } from 'react'
|
|
|
|
|
import { useLocation } from 'react-router-dom'
|
|
|
|
|
|
|
|
|
|
export default function ScrollToTop() {
|
2026-05-27 22:33:54 -05:00
|
|
|
const { pathname, hash } = useLocation()
|
2026-05-27 23:40:09 -05:00
|
|
|
|
fix(ui): a link with an odd fragment took the whole page down
document.querySelector THROWS on a fragment that is not a valid CSS selector,
and an id starting with a digit is not one. The call sat in an effect inside the
root route, so the error boundary replaced the header, the page and the footer
with the error screen. Live today: /services#1 blanks the page. Confirmed in
Chromium, where querySelector('#1') raises a DOMException.
That matters now because the long-form service pages exist to be deep-linked
from search results and AI answers, and two of their sections are about 8x8. A
link nobody here wrote must not be able to blank a page.
getElementById takes an id rather than a selector, so it cannot throw on one,
and the decode is wrapped for a fragment that is not valid percent-encoding.
An unknown id scrolls to the top, as before.
Proven in a real browser against the built site: /services#1, /#%E0 and
/services#8x8-implementation all render with header, footer and h1 intact and no
page error, and /contact#contact-form still scrolls to the form.
That run also confirmed at runtime what #226 suspected from reading the code:
every page logs React error #418, a hydration mismatch. It is pre-existing and
unrelated to this change, and it is fixed next.
Closes #231.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:34:41 -05:00
|
|
|
// Cross-page navigation: scroll to hash or top on route change.
|
|
|
|
|
//
|
|
|
|
|
// getElementById, not querySelector. querySelector THROWS on a fragment that
|
|
|
|
|
// is not a valid CSS selector, and an id starting with a digit is not one:
|
|
|
|
|
// `#8x8-implementation` and even `/#1` would throw here, in an effect inside
|
|
|
|
|
// the root route, and the error boundary would replace the header, the page
|
|
|
|
|
// and the footer with the error screen. The long-form service pages exist to
|
|
|
|
|
// be deep-linked from search results and AI answers, so a link nobody here
|
|
|
|
|
// wrote must never be able to blank the page. An unknown id scrolls to top.
|
2026-05-17 21:37:42 -05:00
|
|
|
useEffect(() => {
|
fix(ui): a link with an odd fragment took the whole page down
document.querySelector THROWS on a fragment that is not a valid CSS selector,
and an id starting with a digit is not one. The call sat in an effect inside the
root route, so the error boundary replaced the header, the page and the footer
with the error screen. Live today: /services#1 blanks the page. Confirmed in
Chromium, where querySelector('#1') raises a DOMException.
That matters now because the long-form service pages exist to be deep-linked
from search results and AI answers, and two of their sections are about 8x8. A
link nobody here wrote must not be able to blank a page.
getElementById takes an id rather than a selector, so it cannot throw on one,
and the decode is wrapped for a fragment that is not valid percent-encoding.
An unknown id scrolls to the top, as before.
Proven in a real browser against the built site: /services#1, /#%E0 and
/services#8x8-implementation all render with header, footer and h1 intact and no
page error, and /contact#contact-form still scrolls to the form.
That run also confirmed at runtime what #226 suspected from reading the code:
every page logs React error #418, a hydration mismatch. It is pre-existing and
unrelated to this change, and it is fixed next.
Closes #231.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 04:34:41 -05:00
|
|
|
if (hash.length > 1) {
|
|
|
|
|
let el = null
|
|
|
|
|
try {
|
|
|
|
|
el = document.getElementById(decodeURIComponent(hash.slice(1)))
|
|
|
|
|
} catch {
|
|
|
|
|
el = null // a fragment that is not valid percent-encoding
|
|
|
|
|
}
|
2026-05-27 22:33:54 -05:00
|
|
|
if (el) {
|
|
|
|
|
el.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-17 21:37:42 -05:00
|
|
|
window.scrollTo(0, 0)
|
2026-05-27 22:33:54 -05:00
|
|
|
}, [pathname, hash])
|
2026-05-27 23:40:09 -05:00
|
|
|
|
|
|
|
|
// Same-page: React Router won't re-navigate if URL is already identical,
|
|
|
|
|
// so intercept clicks on any link pointing to #contact-form directly.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleClick = (e) => {
|
|
|
|
|
const anchor = e.target.closest('a')
|
|
|
|
|
if (!anchor) return
|
|
|
|
|
const href = anchor.getAttribute('href') || ''
|
|
|
|
|
if (!href.includes('#contact-form')) return
|
|
|
|
|
const el = document.querySelector('#contact-form')
|
|
|
|
|
if (!el) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
el.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
|
window.history.pushState(null, '', '#contact-form')
|
|
|
|
|
}
|
|
|
|
|
document.addEventListener('click', handleClick)
|
|
|
|
|
return () => document.removeEventListener('click', handleClick)
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-05-17 21:37:42 -05:00
|
|
|
return null
|
|
|
|
|
}
|