-
-
Notifications
You must be signed in to change notification settings - Fork 156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature request: scroll restoration #300
Comments
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 |
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 I don't know if a third-party solution would work with |
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 believe that. Apr 2023 is a pretty long time ago. :(
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 So other than being able to pass this
Note also that (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. |
Regarding the API, I'm pretty sure we can fit everything in a custom hook. This will allow some hacks to ensure that 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 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 I wonder when the teardown function of // 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
|
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 The core library could be updated in some small ways to make this nicer:
But also this approach is not perfect. Specifically: hash/anchor handling in If we have a 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 |
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 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 still has issues with overwriting the current location on forward/backward navigation. See molefrog/wouter#300. Might still be worthwhile to have anyway.
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
useEffect
rather 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)pagehide
event to capture current scroll position before navigating to another page (also captures refreshing) (docs)state
parameter used in the history API)navigate
the ability to bypass auto-scrolling by setting a flaguseLayoutEffect
call to avoid flickering thatuseEffect
would cause.I think a minimal approach could:
pagehide
events to capture scroll positionhistory.replaceState
) - this can be put into thestate
object 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 timepagehide
fires, the history has (probably?) already updated, so using session storage might be a requirement]useLayoutEffect
withuseLocation()[0]
as a dep. Internally this can checkhistory.state
to see if it needs to scrollhistory.state
object directly - would it be possible to expose this fromuseLocation
somehow?The text was updated successfully, but these errors were encountered: