Repository navigation
Feature request: scroll restoration #300
Description
Activity
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.
Reacted by mashaalconst [ 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
Reacted by Paolo Floresthe 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
0thand the2ndin the array of URLs is equal, I assume they user clicked the back button :) And in all other case, I trigger thewindow.scrollTo(0, 0).I don't know if a third-party solution would work with
wouterunlesswouterexposes more.Reacted by mashaalI 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
LinkandRedirectalso having pass-through arguments to set this second parameter ofsetLocation. Right now that exists for things likereplace, but ideally it would be possible to set arbitrary properties, or at a minimum, possible to set this newnoScrollproperty.
So other than being able to pass this
noScrollproperty 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:beforenavigateneeds to be fired whenever thelocationchanges (using the same logic that triggers the hook to update now), as well as when the user navigates away (via thepagehideevent), 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.
afternavigateneeds to be fired after the page has re-rendered (probably via something likePromise.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
afternavigatemight need to callreplaceState, 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.
Reacted by Samuel Karani and Daniil PankovLinkandRedirectalready proxy all their props to thenavigatemethod. So anything that is passed to the<Link foo="bar" />will appear as an option innavigate.Regarding the API, I'm pretty sure we can fit everything in a custom hook. This will allow some hacks to ensure that
useLayoutEffectis 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.
Reacted by Daniil PankovI assume the
useLayoutEffectOnceis intended to havepathin 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
navigateis called.I wonder when the teardown function of
useLayoutEffectruns; 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
statefrom 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
- 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
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.currentNavigateOptionstracking)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), thecurrentNavigateOptionswill be set, butupdateScrollwill not be called, because thepathfromuseLocationdoesn't change. We also don't get ahashchangeevent 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).- Capturing the
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.eventsfor 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.Reacted by David Evans, Daniil Pankov and Stephanos KomnenosWhy is this feature really necessary? What does the built-in History API scroll restoration (
history.scrollRestoration = true) do wrong that a 3rd party implementation will solve?@Shakeskeyboarde the latest implementation suggestion here is specifically tailored to work with the (default)
scrollRestoration='auto'setting. The original suggestion was based on React-Router's implementation, which for whatever reason turns that feature off in favour of its own approach (perhaps because it was less reliable or less available back when they implemented it). The library/user requirement here is to set an explicit scroll position when navigating to a new page or anchor, but leave the scroll position untouched when navigating via the history (so that scrollRestoration can handle it unhindered).The behaviour of
scrollRestorationis to restore scroll positions when navigating through the history, but to do nothing when navigating to a new page (so e.g. when navigating between 2 large pages, the user may end up half way down the second page when they'd expect to be navigated to the top of the page). This is sometimes desirable (e.g. if the "navigation" is conceptually between tabs on the same apparent page), but in most cases is undesirable and confusing to users. Note that in many (but not all) real-world applications, this behaviour is masked due to loading states which make the page short for a moment, forcing the scroll back to 0 "by accident".On the state of the feature suggestion, which has gone rather stale, I think there isn't much that needs doing (and maybe it's already available now - I haven't checked the latest features):
- exposing the internal events as suggested would make it possible to build a self-contained (user-space) scroll restoration component
- if
afterNavis available, we could fix the remaining issue in my latest post by settingcurrentNavigateOptionstonullinafterNavso that it is cleared even when a link only changes the hash (which is missed by my latest code and causes the state to "leak" into the next history navigation event)
This has been requested before: #132, (partially) #166, #189 - but the suggested code is an incomplete solution:
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
useEffectrather thanuseLayoutEffect. 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:
history.scrollRestoration(docs)pagehideevent to capture current scroll position before navigating to another page (also captures refreshing) (docs)stateparameter used in the history API)navigatethe ability to bypass auto-scrolling by setting a flaguseLayoutEffectcall to avoid flickering thatuseEffectwould cause.I think a minimal approach could:
pagehideevents to capture scroll positionhistory.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]useLayoutEffectwithuseLocation()[0]as a dep. Internally this can checkhistory.stateto see if it needs to scrollhistory.stateobject directly - would it be possible to expose this fromuseLocationsomehow?