Skip to content

Fix fragment navigation and focus handling in client router - #231

Merged
JoviDeCroock merged 1 commit into
mainfrom
claude/hash-scroll-focus-jxtvn7
Jul 27, 2026
Merged

Fix fragment navigation and focus handling in client router#231
JoviDeCroock merged 1 commit into
mainfrom
claude/hash-scroll-focus-jxtvn7

Conversation

@JoviDeCroock

Copy link
Copy Markdown
Owner

Summary

The client router was undoing in-page fragment scrolls and not moving focus to fragment targets, breaking skip links and in-page anchors.

Root cause: Clicking <a href="#section"> fires popstate for a brand new history entry, not a traversal. The router treated every popstate as a back/forward navigation and restored the saved scroll position, which scrolled the page back from the fragment the browser had just jumped to.

Solution: The router now distinguishes fragment navigations from traversals by checking if the scroll key (stamped into history.state for every entry the router creates) is missing AND the path/query are unchanged. When detected, the browser's own jump is left to stand.

Additionally, when the router scrolls to a fragment itself (client-side navigation or traversal with no saved position), it now moves focus to the target by:

  • Adding a temporary tabindex="-1" for non-focusable elements (headings, landmarks)
  • Removing the attribute on blur to leave the DOM as authored
  • Preserving author-provided tabindex attributes
  • Calling focus({ preventScroll: true }) to avoid interrupting CSS scroll-behavior

This restores the sequential focus navigation starting point, which is essential for skip links to work correctly.

Changes:

  • New fragment-navigation.ts module with helpers: decodeFragmentId(), findFragmentTarget(), focusFragmentTarget(), scrollToFragmentTarget()
  • Updated router.ts to detect and handle fragment navigations, and to focus fragment targets
  • Added comprehensive unit tests for fragment navigation helpers
  • Added e2e tests for in-page fragment links (scroll position, focus, back navigation)
  • Added /fragment test route with skip link
  • Updated routing documentation with scroll behavior details and caveats

Testing

  • pnpm e2e — New e2e tests verify fragment link scrolling, focus movement, and back navigation
  • pnpm test — New unit tests for fragment navigation helpers and router popstate handling
  • pnpm format
  • pnpm lint

Checklist

  • Docs updated (ROUTING.md with scroll behavior and fragment navigation details)
  • Changeset added (@pracht/core patch)

https://claude.ai/code/session_01WsGLt6Ro49zViRVddW1tHj

…rgets

Clicking `<a href="#section">` fires popstate for a brand new history entry
rather than a traversal. The router read every popstate as a traversal and
restored the saved scroll position, scrolling the page straight back from the
fragment the browser had just jumped to — so in-page anchors and skip links
appeared not to work.

Tell the two apart by the scroll key the router stamps into history.state for
every entry it creates: a keyless entry whose path and query are unchanged is a
fragment navigation, so the browser's own jump is left to stand. Requiring the
path to match as well means an entry whose state was wiped by app code (a stray
`history.replaceState(null, …)`) is still re-resolved when the route really did
change.

A traversal onto an entry with no saved position now falls back to the URL's
fragment instead of hard-resetting to the top.

Wherever the router scrolls to a fragment itself, it now also moves focus to the
target, adding a temporary `tabindex="-1"` for elements that are not natively
focusable and removing it again on blur. Chromium sets the sequential focus
navigation starting point on its own for a native fragment navigation, but not
when the router scrolls, and Safari has historically not set it at all. Without
this a skip link scrolled but left the next Tab stop at the top of the page.
`scrollIntoView()` is still called with no `behavior` option so a CSS
`prefers-reduced-motion` rule can turn a smooth scroll off.

The bug only manifests when the popstate-triggered navigate() is async: on a
route with no loader the router's scroll reset lands before the browser's jump
and is overwritten. The e2e fixture route therefore has a loader, without which
the regression is invisible.

Also unregister router event listeners between router-navigation tests — without
it, routers from earlier tests stay subscribed to the shared jsdom window and
answer events a later test dispatches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WsGLt6Ro49zViRVddW1tHj
@JoviDeCroock
JoviDeCroock merged commit 08e26e4 into main Jul 27, 2026
3 checks passed
@JoviDeCroock
JoviDeCroock deleted the claude/hash-scroll-focus-jxtvn7 branch July 27, 2026 15:50
@github-actions github-actions Bot mentioned this pull request Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants