Skip to content
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

[1.x] Fix a flash of scrolling-to-top on page changes by using useLayoutEffect #1803

Closed

Conversation

oscarnewman
Copy link

@oscarnewman oscarnewman commented Feb 20, 2024

This is my (attempted) fix for #1802. I outlined the issue in detail there but tl;dr:

  • Inertia/React creates a race condition when changing pages, where resetting the scroll position to (0,0) often happens an instant before the page component itself changes to the next page
  • This creates a flash of unexpected content when you'll scrolled down a page and click a <Link>.

A huge caveat to all of this is that I'm brand new to Inertia as a user, let alone looking at this codebase, so please take anything I suggest with a grain of salt.

Based on how Remix/React-router handle this successfully with a useLayoutEffect, I've slightly adapted a few things:

  1. each framework package (react/vue/etc) can now specific in it's router.init whether it will handle resetting scroll position itself (vs letting the router core decide when to do it)
  2. the swapComponent callback within router.init now passes a new argument, preserveScroll, similar to how it passes preserveState. This and (1) mean React has the information needed to manage scroll resetting in it's own render lifecycle
  3. router.resetScrollPositions() is now public
  4. Existing calls to router.resetScrollPositions() adjacent to the swapComponent call have been replaced with router.resetScrollPositionsIfNotHandledExternally() -- which just does nothing if the handleScrollResetsExternally flag is set by the framework package. Other calls to router.resetScrollPositions() weren't changed as they don't interact with the swapComponent function and don't seem to suffer the same issue

I've also updated the React playground with a sticky header so that the fix can be validated there as well.

Testing so far:

  1. Tested the fix in the playground ✅
  2. Linked the rebuild inertia into my own app, and the more aggressive flashes are gone ✅
  3. Ensured that preserveScroll still works on Link and Router calls ✅

@@ -484,7 +496,7 @@ export class Router {
Promise.resolve(this.resolveComponent(page.component)).then((component) => {
if (visitId === this.visitId) {
this.page = page
this.swapComponent({ component, page, preserveState: false }).then(() => {
this.swapComponent({ component, page, preserveState: false, preserveScroll: true }).then(() => {
this.restoreScrollPositions()
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I can tell, restoreScrollPositions() doesn't suffer from this same issue in my testing. I'm not entirely sure why, to be honest.

setCurrent((current) => ({
component,
page,
key: preserveState ? current.key : Date.now(),
preserveScroll: !!preserveScroll,
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both preserveState and preserveScroll are typed as a PreserveStateOption in the router core, although they actually seem to be resolved to booleans where it matters in the usage here?

This is where my knowledge of this codebase starts to feel super minimal -- so I may be way off here

@@ -5,7 +5,7 @@ export default function Layout({ children }) {

return (
<>
<nav className="flex items-center space-x-6 bg-slate-800 px-10 py-6 text-white">
<nav className="sticky top-0 flex items-center space-x-6 bg-slate-800 px-10 py-6 text-white">
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allows for reproduction to be tested -- you need to be able to click a link while in a scrolled-down state

Copy link
Collaborator

@pedroborges pedroborges left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just tested this locally and reproduced it, adding a red background to the article title and throttling CPU and network makes it easier to spot it. Nice work @oscarnewman 👏

Before fix

before-fix.mov

After fix

after-fix.mov

The core has been rewritten to support async requests, the code that handles scroll moved to the packages/core/src/scroll.ts file. Let's wait until that work is merged into master to rebase and merge this one 👍

#1796 also fixes a scroll restoration bug in the React adapter. These PRs conflict with each other but that will be easy to consolidate.

@@ -40,19 +40,23 @@ export class Router {
protected navigationType?: string
protected activeVisit?: ActiveVisit
protected visitId: VisitId = null
protected handleScrollResetExternally: boolean = false
Copy link
Collaborator

@pedroborges pedroborges Sep 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: customScrollResetHandler

@@ -134,6 +138,14 @@ export class Router {
}
}

protected resetScrollPositionsIfNotHandledExternally(): void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: attemptScrollReset

@pedroborges pedroborges added bug Something isn't working react Related to the react adapter labels Sep 6, 2024
@pedroborges pedroborges added core Related to the core Inertia library and removed bug Something isn't working labels Sep 6, 2024
@pedroborges
Copy link
Collaborator

While searching for ways simplify this solution and trying to avoid handling scroll outside core, I came across a pattern called double RAF. It combines two requestAnimationFrame to ensure that a callback runs after the next repaint. Vue uses this pattern internally:

I tested it and it solves the scrolling glitch in React. Since this pattern is safe for the other adapters, we can implement a fix inside core without requiring any changes to the React adapter.

We can also replace it where we currently use a setTimeout to "delay" scrolling.

@pedroborges
Copy link
Collaborator

pedroborges commented Sep 10, 2024

Found other issues related to this one: #1211, #1698, #1816, and #1922.

@pedroborges pedroborges changed the title React: Fix a flash of scrolling-to-top on page changes by using useLayoutEffect [1.x] Fix a flash of scrolling-to-top on page changes by using useLayoutEffect Sep 13, 2024
@pedroborges pedroborges removed the request for review from joetannenbaum September 17, 2024 19:54
pedroborges added a commit that referenced this pull request Sep 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
core Related to the core Inertia library react Related to the react adapter
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants