T
TanStack3mo ago
optimistic-gold

Loading data in the <Route> with `useSuspenseQuery` forces suspending and scroll reset (w/ example)

I'm trying to fetch data in the loader of a <Route /> that depends on search params and I'm not able to figure out how to do it right (?). I created a sample repo: https://stackblitz.com/edit/tanstack-router-aiytgf7a?file=src%2Froutes%2Findex.tsx Current behavior I'm changing the query param and my app goes blank, suspends and renders the new data with a scroll reset (worst case). Desired behavior I'm changing the query parm, partial cache match, the stale data remains on screen, fetch in the background, data is replaced. No scroll reset, no suspending. Reproduce Go to sample repo. Scroll down, click on a button. Wait for the scroll reset, see the green screen (pending component). I want to get rid of both (green screen + scroll reset)
fmart
StackBlitz
data-fetching-in-loader - StackBlitz
Run official live example code for Router Basic React Query File Based, created by Tanstack on StackBlitz
15 Replies
optimistic-gold
optimistic-goldOP3mo ago
I created a second sandboy where I use resetScroll=false and startTransition from react. Still no success: https://stackblitz.com/edit/tanstack-router-6cmyjlwc?file=src%2Froutes%2Findex.tsx
fmart
StackBlitz
data-fetching-in-loader (duplicated) - StackBlitz
Run official live example code for Router Basic React Query File Based, created by Tanstack on StackBlitz
wee-brown
wee-brown3mo ago
The fork seems to have the desired behavior for me In your first repro the default pendingMs I think is the same as your timeout (1500ms) so you sometimes get a suspense hit Not sure how to avoid it altogether, startTransition is indeed the approach with useNavigate() but I haven't run into this scenario myself yet with links
optimistic-gold
optimistic-goldOP3mo ago
Hey! Thanks for looking into it. Yeah. I got it to work on the fork. But in my actual app I still see flashing screens and scroll bouncing.
wee-brown
wee-brown3mo ago
are you overriding the defaultPendingMs when creating the router, by any chance?
optimistic-gold
optimistic-goldOP3mo ago
I was able to crack it after all. The problem was a.) the missing resetScroll=false properties on all navigations and b.) there was a slight mismatch between my useSuspenseQuery paramters and the ensureQueryData parameters. These two have to be kept in sync 100000%. If they aren't the useSuspenseQuery will suspend and if it's not wrapped in a top-level <Suspense /> escalate to the Route-level pendingComponent.
wee-brown
wee-brown3mo ago
Glad you found it! Are you still overriding pendingMs on route level to avoid triggering the route's pendingComponent when clicking the link?
optimistic-gold
optimistic-goldOP3mo ago
yes, for sure. I think the default is 1s and for longer running queries that is not enough. As soon as the component suspends the scroll will be reset. And while it suspends the old data is shown. The only downside (AFAICT) is that I'm not able to show the pending component on first load with this approach.
wee-brown
wee-brown3mo ago
I'm guessing you could for example make a wrapper Link component with a custom withTransition={true|false} prop that passes the to to the router's Link (so you can cmd+click or right-click+open in new tab) but also dynamically (depending on the withTransition value) sets an onClick handler that prevents the default event and does a startTransition+navigate(props). It should let you omit the custom pendingMs if I understand everything correctly. Not sure if there's currently any better way to integrate a link within Suspense. With withTransition={true} you'd then override the default link behavior and opt into Suspense loading with a navigate(), when set to false you'd get default link behavior
optimistic-gold
optimistic-goldOP3mo ago
Trying to wrap my head around some new concepts 😵‍💫. So, I'm currently overwriting the pendingMs to prevent the route from showing the pendingComponent. The pendingComponent will reset the scroll state of the user, leading to a bad UX. The flow goes like this: 1. User clicks button 2. Query param is updated 3. Route is marked as pending 4. Tanstack router flow (loader is executed) 5. If the loading takes too long (>1s) the pending component will reset the scroll state of the user (bad)
To give the loader more time I'm overwriting pendingMs with a larger value. Just repeating this so we're talking about the same thing.
optimistic-gold
optimistic-goldOP3mo ago
My knowledge about startTransition is minimal. The Query docs mention that useSuspenseQuery does not have placeholderData and we should use startTransition. But AFAICT my problem is not the call touseSuspenseQuery. My problem is the route loader. The loader must finish for the route to be marked as resolved. As soon as it does so, the page will be rerendered and useSuspenseQuery can fetch the data from the cache (the cache was seeded in the loader with ensureQueryData. What I want to achieve is classic stale while revalidate behavior. 1. Query params get updated 2. Stale data is shown 3. Fetch in the background completes (can take a long time ... no need to suspend or show pendingComponent) 4. Render page with new data I'm trying to understand if your response was addressing this flow, @pleunv . Thanks @Manuel Schiller , will take a look! @Manuel Schiller hm, I don't think the GH thread offers any solution. There are a couple proposals but they all fall short IMO. But it's good to know that many people seem to have this problem. It seems that Tanstack Router does not have a solution for this problem currently.
genetic-orange
genetic-orange3mo ago
useDeferredValue?
optimistic-gold
optimistic-goldOP3mo ago
Oh, kinda brushed over that part. Will read the article from Frontend Masters tomorrow. See if that helps and report back. Thanks!
wee-brown
wee-brown3mo ago
What I had in mind was something like this (not tested it):
function SuspendableLink({
children,
withTransition,
onClick,
...props
}: Omit<LinkComponentProps, 'children'> & {
children: ((isPending: boolean) => ReactNode) | ReactNode;
withTransition: boolean;
}) {
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();

let handleClick = onClick;

if (withTransition) {
handleClick = function onClick(evt) {
evt.preventDefault();
startTransition(async () => {
onClick?.(evt);
await navigate(props);
});
};
}

return (
<Link {...props} onClick={handleClick}>
{typeof children === 'function' ? children(isPending) : children}
</Link>
);
}
function SuspendableLink({
children,
withTransition,
onClick,
...props
}: Omit<LinkComponentProps, 'children'> & {
children: ((isPending: boolean) => ReactNode) | ReactNode;
withTransition: boolean;
}) {
const navigate = useNavigate();
const [isPending, startTransition] = useTransition();

let handleClick = onClick;

if (withTransition) {
handleClick = function onClick(evt) {
evt.preventDefault();
startTransition(async () => {
onClick?.(evt);
await navigate(props);
});
};
}

return (
<Link {...props} onClick={handleClick}>
{typeof children === 'function' ? children(isPending) : children}
</Link>
);
}
I've not used useDeferredValue in this context yet myself but that looks like perhaps a slightly easier solution The advantage of the link wrapper is that you could get access to the pending state and show a spinner. There's other ways to do this (e.g. using MatchRoute or looking at the global navigation status iirc - but in these cases you can't know for sure if the link was the initiator of the navigation, you need an event-based solution for that) edit: Doesn't seem to work, not entirely sure why. Seems to conflict with transitions that router is running internally

Did you find this page helpful?