livewire/livewire: Avoid fetching and swapping for same-page fragment navigation
Contributed by calebporzio
Part of the livewire v4.4.7 release
If your app uses wire:navigate links that point to fragments on the page you are already on—say from /posts to /posts#comments—Livewire used to run the full navigation pipeline: fetch the page, swap the DOM, re-initialize Alpine. Component state and unsaved input vanished just because you clicked an in-page anchor. Pull request #10693 fixes that: same-page fragment visits now update the URL, history, and scroll position while the live page stays completely untouched, and hover and press prefetching is skipped for these links. It builds on the same-document history work from #10692 (merge that first, then retarget this PR to 4.x), and it directly addresses a pain point for anyone upgrading from v3.8.10.
Fragment links used to cost a full page load
Consider a typical post page:
<input type="text" wire:model="draft" placeholder="Note to self...">
<a href="#comments" wire:navigate.hover>Jump to comments</a>
<section id="comments">
{{-- long comment thread --}}
</section>
Before this fix, clicking that link while already on /posts re-fetched /posts, swapped the document, and re-rendered every component on it. A deferred wire:model="draft" input was wiped, Alpine state was reset, and—worse—merely hovering the link for 60 milliseconds fired a prefetch request for a page you were already looking at. For anchors that exist purely to scroll the viewport, all of that work was pure overhead.
What the shortcut does
Livewire now recognizes a wire:navigate destination as a same-page fragment when the origin, path, and query string all match the current URL and the href contains a #. In that case, navigateTo() short-circuits before any fetching: it stores the current scroll information, pushes the fragment URL onto history (flagged as belonging to the same document), and restores the scroll position or scrolls to the fragment target. No network request, no DOM swap, no re-initialization.
Several edge cases are handled explicitly:
- Clicking the same fragment link again scrolls back to the target without adding another history entry.
- A link to an empty fragment such as
href="#"scrolls to the top of the page. wire:navigate.preserve-scroll—and its programmatic equivalent—update the URL while leaving the scroll position alone:
Livewire.navigate('/posts#replies'); // scrolls to #replies
Livewire.navigate('/posts#replies', { preserveScroll: true }); // URL changes, scroll does not
- A different path or query string (
/posts?page=2#comments) still performs an ordinary fetch-and-swap navigation, and visiting the current URL without a fragment still refreshes the page.
Back/Forward keeps the live page
Fragment history entries now carry a shared document ID. When the user presses Back or Forward and the entry belongs to the current document, Livewire skips snapshot restoration entirely: your components stay live while the browser restores the viewport scroll position natively. Scroll containers marked with wire:navigate:scroll (or x-navigate:scroll) also get their saved positions restored—without their DOM being replaced—by matching an internal data-scroll-id attribute against the saved page state, so nested scrollable panes survive the trip. Cross-page history entries still use the existing snapshot/fetch path, so normal navigation is unchanged.
Events to review when upgrading
Same-page fragment visits keep the cancellable livewire:navigate event, so event.preventDefault() still stops the jump, and they fire livewire:navigated when scrolling completes. What they do not fire is livewire:navigating—the pre-swap event—because nothing is swapped:
document.addEventListener('livewire:navigate', (e) => {
// still cancellable for fragment visits
});
document.addEventListener('livewire:navigating', () => {
// does NOT fire for same-page fragment visits (no swap happens)
});
document.addEventListener('livewire:navigated', () => {
// still fires once a fragment visit finishes
});
For most apps this is exactly the desired behavior, but it is the one behavioral difference worth auditing when you upgrade.
Upgrading from v3.8.10
There are no API changes: wire:navigate, its modifiers, and Livewire.navigate() behave as documented—this is strictly an optimization with a small event-footprint change. Check two things in your app. First, listeners on livewire:navigating that you expected to run on every fragment click; they will now only run on real page swaps. Second, any code that assumed livewire:navigated implies a fresh DOM—fragment visits fire it without replacing anything, so re-initialization logic should stay idempotent. The fix ships with eleven new browser tests covering state and input preservation, hover, keyboard, and programmatic navigation, repeated and empty fragments, cancellation, scroll-container restoration, query-string changes, and history across other pages.
Takeaways
- Same-page
wire:navigatefragment links (e.g.,/posts→/posts#comments) no longer fetch or swap the page; component state and unsaved input survive. - Hover and press prefetching is skipped for fragment links, eliminating wasted requests.
- Back/Forward across fragment entries keeps the live page and restores scroll, including
wire:navigate:scrollcontainers, without DOM replacement. livewire:navigate(cancellable) andlivewire:navigatedstill fire;livewire:navigatingdoes not fire for fragment visits.- Path or query-string changes still perform a full fetch-and-swap navigation.