Feature request: scroll restoration
This has been requested before: #132, (partially) #166, #189 - but the suggested code is an incomplete solution:
import { useEffect } from "react";
import { useLocation } from "wouter";
export default function ScrollToTop() {
const [ pathname ] = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
This will scroll to the top of the page on any navigation, including when the user presses the back / forward history buttons, and when the user navigates in ways that only affect part of the page (e.g. changing a tab component which records the current tab in the URL). It will also have a brief flicker due to using useEffect rather than useLayoutEffect. A more complete solution would only reset the scroll position when navigating to new pages, restoring previous positions when navigating forward / back, and allowing an override on links to disable scrolling to the top of the page.
React Router provides this as a separate component, but with some necessary integration with the core. That seems like a reasonable approach to follow here too, since it fits the philosophy of not bloating the core library (saving space for people who don't need the feature).
https://reactrouter.com/en/main/components/scroll-restoration
Their (MIT licensed) code shows that there are quite a few considerations here, so it seems beneficial to offer this in the library rather than having each user recreate the functionality for themselves. This will also make it possible to add integrations such as allowing specific links to bypass the scrolling behaviour.
Generally, their approach:
- takes over the native scroll behaviour (i.e. when the whole page actually switches) by setting
history.scrollRestoration(docs) - listens for the
pagehideevent to capture current scroll position before navigating to another page (also captures refreshing) (docs) - stores scroll locations in session storage (likely to avoid taking over the
stateparameter used in the history API) - provides a custom implementation of scrolling to anchor elements (likely due to disabling the browser's native handling; this might not be required, but worth finding out what the disadvantages are of allowing native scroll handling)
- gives links /
navigatethe ability to bypass auto-scrolling by setting a flag - updates the scroll position inside a
useLayoutEffectcall to avoid flickering thatuseEffectwould cause.
I think a minimal approach could:
- listen for
pagehideevents to capture scroll position - record this position in the history API for the current page (by calling
history.replaceState) - this can be put into thestateobject since currently it's always just set tonull, which avoids the need to use session storage [edit: actually this probably won't work because by the timepagehidefires, the history has (probably?) already updated, so using session storage might be a requirement] - set up a
useLayoutEffectwithuseLocation()[0]as a dep. Internally this can checkhistory.stateto see if it needs to scroll- it would be better if this could depend on the
history.stateobject directly - would it be possible to expose this fromuseLocationsomehow?
- it would be better if this could depend on the
To clarify: I'm happy to work on a PR for this, but I want to make sure there's agreement on an approach first.
const [ pathname ] = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
is not a best option, ideally we don't restore scroll on replace event
the solution should respect history back and replace state, where we want to keep the scroll
the solution should respect history back and replace state, where we want to keep the scroll
Do you know how that can be achieved?
This missing feature is a game changer. I'm really eager for a solution. Even if it comes as a third party package. After all, just like the naive sample hook snippet at the top of the issue, it can all come from something/somewhere else.
In my app that I'm working on I wrote my own horrible version. Total hack. It essentially stores the previous URLs loaded, and if the 0th and the 2nd in the array of URLs is equal, I assume they user clicked the back button :) And in all other case, I trigger the window.scrollTo(0, 0).
I don't know if a third-party solution would work with wouter unless wouter exposes more.
I don't believe this can be done purely from a third-party plugin; it needs some level of integration with the core route handling.
You can see my original post for a full walkthrough of what would be needed for this feature, which draws from the way it is implemented by react-router. I was hoping to hear back from the maintainers about whether this would be a feature they'd consider, especially since it's likely to increase the library size at least a bit.
I don't believe this can be done purely from a third-party plugin; it needs some level of integration with the core route handling.
I believe that. Especially once you get into the finer points, as you laid out.
Apr 2023 is a pretty long time ago. :( You mentioned,
To clarify: I'm happy to work on a PR for this, but I want to make sure there's agreement on an approach first.
Perhaps it's best to come with code then. If the maintainer(s) isn't available, I'd be willing at least to help out. I can test and and I can review. Hopefully that will make the final maintainer review easier and faster. Granted, you do make the point that " it's likely to increase the library size at least a bit."
@molefrog What's your take on this feature? Do you consider it to be in scope? Are you open to community contributions?
Hey everyone, this def sounds like an essential feature to have. I'm freezing the v2 branch as we've just rolled out the new version. I reworked the internals a bit so probably it might help. What are the extensions that the core needs?
@molefrog I could imagine an implementation like this in user-space:
myBrowserLocationRouter.addEventListener('beforenavigate', (e) => {
const historyID = e.detail.oldPage?.state;
if (historyID) {
if (e.detail.navigateOptions.replace) {
sessionStorage.deleteItem(`history-${historyID}`);
return;
}
const pageState = sessionStorage.getItem(`history-${historyID}`);
sessionStorage.setItem(`history-${historyID}`, {
...pageState,
scrollX: window.scrollX,
scrollY: window.scrollY,
backScroll: !e.detail.navigateOptions.noScroll,
});
}
});
myBrowserLocationRouter.addEventListener('afternavigate', (e) => {
const scroll = !e.detail.navigateOptions.noScroll;
if (!e.detail.newPage.state) {
const historyID = crypto.randomUUID();
history.replaceState(historyID);
if (scroll) {
window.scrollTo(0, 0);
}
sessionStorage.setItem(`history-${historyID}`, {
scrollX: window.scrollX,
scrollY: window.scrollY,
forwardScroll: scroll,
});
} else {
const historyID = e.detail.newPage.state;
const pageState = sessionStorage.getItem(`history-${historyID}`);
if (!pageState) {
return;
}
if (e.detail.type === 'back' && !pageState.backScroll) {
return;
}
if (e.detail.type === 'forward' && !pageState.forwardScroll) {
return;
}
window.scrollTo(pageState.scrollX, pageState.scrollY);
}
});
Then in some component:
const [location, setLocation] = useLocation();
return (
<div>
<button onClick={() => setLocation('/p1')}>Page 1</button>
<button onClick={() => setLocation('/p2')}>Page 2</button>
<div>
<button onClick={() => setLocation('/p1/t1', { noScroll: true })}>Tab 1</button>
<button onClick={() => setLocation('/p1/t2', { noScroll: true })}>Tab 2</button>
</div>
</div>
);
With Link and Redirect also having pass-through arguments to set this second parameter of setLocation. Right now that exists for things like replace, but ideally it would be possible to set arbitrary properties, or at a minimum, possible to set this new noScroll property.
So other than being able to pass this noScroll property through the helper objects, there's not much in the main API that needs changing. The bulk of the changes are in the browser router:
beforenavigate needs to be fired whenever the location changes (using the same logic that triggers the hook to update now), as well as when the user navigates away (via the pagehide event), and ideally is not fired when the page first loads. It needs to be fired before the page re-renders (i.e. before any hooks see the new value), and needs to be given:
oldPage: the page we are navigating from. Ideally containsurlandstatefrom the history APInavigateOptions: if the navigation comes fromsetLocationetc., this should be the second argument which was passed. If it comes from browser events, this should be{}ornullor similar.
afternavigate needs to be fired after the page has re-rendered (probably via something like Promise.resolve().then(afternavigate)), and needs to be fired when the page is first loaded (and ideally not fired when the page is closed). It needs to be given:
newPage: the new page we have navigated to. Ideally containsurlandstatefrom the history APInavigateOptions: same asbeforenavigatetype:"back"/"forward"/"navigate"depending on the trigger for the current navigation
Note also that afternavigate might need to call replaceState, as in this example, so there needs to be some handling to avoid infinite loops.
(for simplicity I think it would be nice if both events got the same set of data)
I think adding these callbacks to the browser router represents the bulk of the core features needed to make scroll restoration possible.
Link and Redirect already proxy all their props to the navigate method. So anything that is passed to the <Link foo="bar" /> will appear as an option in navigate.
Regarding the API, I'm pretty sure we can fit everything in a custom hook. This will allow some hacks to ensure that useLayoutEffect is called only once per navigation but I'm confident we can make this work.
import { useLocationWithScrollRestoration } from "wouter/scroll-restoration"
<Router hook={useLocationWithScrollRestoration}>
<Link to="/" preserveScroll={false} />
</Router>
// implementation ideas
import { useBrowserLocation } from "wouter/use-browser-location";
export const useLocationWithScrollRestoration = (router) => {
// all location hooks receive router object when called
// we can use it as a singleton and store app-wide data
// the problem is that new routers are created when they are inherited from parent
// but I can extend the core to support something like `router.store.foo = "bar"`
const [path, navigate] = useBrowserLocation(router)
useLayoutEffectOnce(() => {
// after navigate
})
const navigateWithBefore = () => {
// before navigate
navigate()
}
return [path, navigateWithBefore]
}
This is for V3.
I assume the useLayoutEffectOnce is intended to have path in its deps?
I think the potential gotcha is that (in the form I illustrated above), both events need to fire for all kinds of navigation (i.e. when using the browser back/forward buttons as well as when navigating programmatically), but in your code the "before" event will only fire when navigate is called.
I wonder when the teardown function of useLayoutEffect runs; I'll experiment to see if it works, but something like this might actually work: (or potentially this could run the teardown too "late"; after the page has already been updated with some new content and - critically - the page height has updated)
// assume beforeNavigate & afterNavigate behave as I illustrated in my earlier comment
export const useLocationWithScrollRestoration = (router) => {
const [path, navigate] = useBrowserLocation(router)
useLayoutEffectOnce(() => {
if (router.state.currentNavigateOptions) {
afterNavigate({
newPage: { path, state: history.state },
navigateOptions: router.state.currentNavigateOptions,
type: "navigate",
})
router.state.currentNavigateOptions = null
} else {
afterNavigate({
newPage: { path, state: history.state },
navigateOptions: {},
type: "??", // TODO: detect forward or back (necessary for correct handling of noScroll)
// this might be possible if we carefully pick history.state values to be incrementing
})
}
router.state.lastPageState = history.state
return () => {
// Note that here, router.state.currentNavigateOptions is from the call that
// triggered the current teardown, NOT the same as its value above.
// Also, lastPageState has not been updated yet, so it refers to the previous
// page's history.state
beforeNavigate({
oldPage: { path, state: router.state.lastPageState },
navigateOptions: router.state.currentNavigateOptions ?? {},
})
}
}, [path])
useEffect(() => {
const handle = () => {
beforeNavigate({
oldPage: { path, state: router.state.lastPageState },
navigateOptions: {},
})
}
window.addEventListener('pagehide', handle)
return () => window.removeEventListener('pagehide', handle)
}, [path])
const wrappedNavigate = (...args) => {
// if this capturing could happen internally in the standard `navigate`,
// we could make this whole hook independent of `useLocation`, so users
// could just include it once (saving the need to make useLayoutEffectOnce)
router.state.currentNavigateOptions = args[1] ?? {}
navigate(...args)
}
return [path, navigate]
}
Note this adds some extra handling to track the state from the history API. This is needed for 2 reasons:
- to cope with situations where (e.g.) the user navigates from page 1 to page 2, then back to page 1 (via a link, not history), then navigates back through the history: page 1 has 2 scroll positions depending on which history entry we are at, so the
pathalone isn't sufficient - if the user leaves the page (e.g. following an external link) then returns via the history, we can't rely on local variables - all storage needs to be in the history stack and/or session storage
so it turns out to be absurdly simple to get something which 99% works:
import { useLayoutEffect } from 'react';
import { useLocation } from 'wouter';
import { useBrowserLocation } from 'wouter/use-browser-location';
const hypotheticalRouterGlobalState = {};
export const interceptingHook = (router) => {
const [path, navigate] = useBrowserLocation(router);
const wrappedNavigate = (...args) => {
hypotheticalRouterGlobalState.currentNavigateOptions = args[1] ?? {};
navigate(...args);
// ideally we'd probably clear currentNavigateOptions here (instead of in updateScroll)
// Perhaps something like:
// setTimeout(() => { hypotheticalRouterGlobalState.currentNavigateOptions = null; }, 0);
};
return [path, wrappedNavigate];
};
const updateScroll = () => {
const options = hypotheticalRouterGlobalState.currentNavigateOptions;
hypotheticalRouterGlobalState.currentNavigateOptions = null;
if (!options || options.noScroll) {
return;
}
const hash = document.location.hash?.substring(1);
const target = hash ? document.getElementById(hash) : null;
if (target) {
target.scrollIntoView({ behavior: 'instant', block: 'start', inline: 'nearest' });
} else {
window.scrollTo(0, 0);
}
};
export const ScrollRestorer = () => {
const [path] = useLocation();
useLayoutEffect(updateScroll, [path]);
return null;
};
usage:
<Router hook={interceptingHook}>
<ScrollRestorer />
...
</Router>
The browser's own scroll restoration is perfectly able to cope with the history navigation, just as long as we can stay out of its way and only force the scroll position when navigating ourselves (hence the hypotheticalRouterGlobalState.currentNavigateOptions tracking)
The core library could be updated in some small ways to make this nicer:
- Capturing the
currentNavigationOptionsin the built-inuseBrowserLocationhook (i.e. in the core library) would save the need for a custom hook entirely. Then<ScrollRestorer />would be the only thing users need to add to enable this feature. - Relatedly, putting
hypotheticalRouterGlobalStateinto the router somehow, as you hinted at above, would make for slightly nicer encapsulation.
But also this approach is not perfect. Specifically: hash/anchor handling in Links fails:
If we have a <Link href="#my-anchor"> (linking to an anchor in the current page), the currentNavigateOptions will be set, but updateScroll will not be called, because the path from useLocation doesn't change. We also don't get a hashchange event fired (because we changed it programmatically)
If we could get the current hash from the router, then it could be added as one of the deps in the layout effect, which would fix this. As far as I can tell this isn't currently available by any means, so would need to be added to the core library (either as part of useLocation, or in a new hook).
Regarding the state, I haven't come up with anything more elegant than this. The plus is that it doesn't require us to modify the library:
const store = new WeakMap()
/*
Returns an object that can keep state associated with the current location hook
*/
const useLocationState = () => {
const hook = useRouter().hook;
if (!store.has(hook)) {
store.set(hook, {})
}
return store.get(hook)
}
I don't think that storing navigation parameters should be part of the library though. This sounds like a narrow use case. We could provide a way to hook into internal events via router.events for example:
export const ScrollRestorer = () => {
const state = useLocationState();
const router = useRouter();
useLayoutEffect(() => {
const ubsub = router.events.on("beforeNav", (to, options) => {
state.currentNavigateOptions = { to, options }
});
return () => unsub();
}, [router, state])
useLayoutEffect(() => {
const options = state.currentNavigateOptions;
// ... restore the scroll
}, [path]);
return null;
};
The obvious benefit is that this is location hook independent. This is just a draft idea, I will keep exploring if we could easily integrate it without sacrificing the size.