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

Next 12.3.1 breaks smooth-scrolling, change made in 12.3.1-canary.4, woks in 12.3.1-canary.3 and before #40719

Closed
1 task done
gurkerl83 opened this issue Sep 20, 2022 · 18 comments · Fixed by #43673
Closed
1 task done
Assignees
Labels
bug Issue was opened via the bug report template.

Comments

@gurkerl83
Copy link
Contributor

gurkerl83 commented Sep 20, 2022

Verify canary release

  • I verified that the issue exists in the latest Next.js canary release

Provide environment information

Operating System:
Platform: darwin
Arch: x64
Version: Darwin Kernel Version 21.6.0: Sat Jun 18 17:07:25 PDT 2022; root:xnu-8020.140.41~1/RELEASE_X86_64
Binaries:
Node: 16.13.0
npm: 8.1.0
Yarn: 3.2.3
pnpm: N/A
Relevant packages:
next: 12.3.1-canary.3
eslint-config-next: N/A
react: 0.0.0-experimental-8951c5fc9-20220915
react-dom: 0.0.0-experimental-8951c5fc9-20220915

What browser are you using? (if relevant)

No response

How are you deploying your application? (if relevant)

No response

Describe the Bug

@timneutkens @cramforce The following commit completely disables smooth-scrolling- #40642. We use the following custom hook here to trigger the scroll-behaviour we want. We do not have anything else like a global css which sets the scroll-behaviour on html.

The scroll-behaviour becomes non-deterministic in version 12.3.1-canary.4, the previous version 12.3.1-canary.3 works perfectly. The latest release 12.3.1 is also affected.

import { MittEmitter } from '@app/render-utils';
import { useEffect } from 'react';

/**
 * The scrolling behavior is always set to smooth, when a page mounts.
 * On route change, the scrolling behavior switches to auto before it snaps back to smooth.
 *
 * Note:
 * Do not use the nextJs hook "use-router" in this custom hook; constant re-renders get executed;
 * Unfortunately, it seems there are some bugs in the router implementation (asPath / hash-change).
 *
 * Instead, the nextJs router events object from the _app component is given as an argument.
 */

export const useSmoothScroll = (events: MittEmitter) => {
  const setSmoothScroll = (isSmooth: boolean) => {
    document.documentElement.style.scrollBehavior = isSmooth
      ? 'smooth'
      : 'auto';
  };

  useEffect(() => {
    setSmoothScroll(true);

    const handleRouteChangeStart = () => setSmoothScroll(false);
    const handleRouteChangeComplete = () => setSmoothScroll(true);

    events.on('routeChangeStart', handleRouteChangeStart);
    events.on('routeChangeComplete', handleRouteChangeComplete);

    return () => {
      events.off('routeChangeStart', handleRouteChangeStart);
      events.off('routeChangeComplete', handleRouteChangeComplete);
    };
  }, []);
};

Can you please provide feedback on how to enable scroll-behaviour using the hook again?
Thx!

Expected Behavior

Identical behaviour like in 12.3.1-canary.3

Link to reproduction

I will not create a reproducer for things y break; and smooth-scrolling is something which broke a few times in the past.

To Reproduce

Use the hook from above in _app.

@gurkerl83 gurkerl83 added the bug Issue was opened via the bug report template. label Sep 20, 2022
@gurkerl83 gurkerl83 changed the title Smooth-Scrolling works in 12.3.1-canary.3 and before, but breaks in 12.3.1-canary.4 and 12.3.1 Next 12.3.1 breaks smooth-scrolling, change made in 12.3.1-canary.4, woks in 12.3.1-canary.3 and before Sep 21, 2022
@gurkerl83
Copy link
Contributor Author

gurkerl83 commented Oct 8, 2022

@timneutkens @cramforce It seems that several people confirm this problem. Have you taken a look at this problem yet?

@noxzym
Copy link

noxzym commented Nov 11, 2022

any update for this?

@timneutkens
Copy link
Member

@gurkerl83 I'm trying to understand what the problem is with the change that was landed, it implements exactly the behavior that is intended based on your hook: before it scrolls to top during navigation it sets it to auto, after it scrolled it sets it to what the value was before scrolling. Seems you can remove that hook and it'll work the way you want it to work?

@gurkerl83
Copy link
Contributor Author

@timneutkens I disabled the hook, and configured scrolling with css. The result was really not the same, compared with the hook enabled using a nightly version of Next right before the change landed. For the next two weeks I’m traveling without any device on hand, but I can provide a reproducer afterwards. Thx for looking into that!

@gurkerl83
Copy link
Contributor Author

gurkerl83 commented Nov 29, 2022

@timneutkens I spent a bit of time on this problem yesterday.

before it scrolls to top during navigation it sets it to auto, after it scrolled it sets it to what the value was before scrolling.

In fact, the implementation seems to be wrong, as the scroll behaviour "auto" immediately or during scrolling switches back to "smooth" or the globally defined behaviour of the html tag.

Local experiments showed that the scrolling behaviour "auto" must be initiated at the start of a route change "routeChangeStart" and must be maintained until a route change is completed "routeChangeComplete".

Pre-condition:
The scroll behaviour "smooth" must either be defined globally in css for the html element. From the hook provided this is done once in the effect-lifecycle, however a user defined configuration seems better, especially as the behaviour can be integrated into Next as described in the next step.

Summary of the lifecycle:

  1. Subscribe to routeChangeStart, routeChangeComplete
  2. When the route change starts - switch to auto -> scroll immediately to the top / hold this value until the route change finishes.
  3. When the route change finishes - switch back to smooth (or whatever was before or globally defined) -> e.g. hash changes are not route changes -> results in smooth scrolling.

In the next step I describe the necessary changes

  • next/client/index.tsx -> component Root

In doRender, where the mounting of Root occurs

renderReactElement(appElement!, (callback) => (
    <Root router={router} callbacks={[callback, onRootCommit]}> // pass router

In the component Root
First variant, improved by second version further down

const setSmoothScroll = (isSmooth: boolean) => {
   document.documentElement.style.scrollBehavior = isSmooth ? 'smooth' : 'auto';
};

// We should ask to measure the Web Vitals after rendering completes so we
// don't cause any hydration delay:
useEffect(() => {
   measureWebVitals(onPerfEntry)

   const handleRouteChangeStart = () => {
      setSmoothScroll(false);
   }
   
   const handleRouteChangeComplete = () => {
      setSmoothScroll(true);
   }

   router.events.on('routeChangeStart', handleRouteChangeStart);
   router.events.on('routeChangeComplete', handleRouteChangeComplete);

   return () => {
      router.events.off('routeChangeStart', handleRouteChangeStart);
      router.events.off('routeChangeComplete', handleRouteChangeComplete);
   };
}, [])

Second variant, remember the initial scroll-behaviour defined for a global css html tag

const refScroll = React.useRef(null)

// We should ask to measure the Web Vitals after rendering completes so we
// don't cause any hydration delay:
useEffect(() => {
   measureWebVitals(onPerfEntry)

   const handleRouteChangeStart = () => {
      refScroll.current = document.documentElement.style.scrollBehavior;
      document.documentElement.style.scrollBehavior = 'auto';
   }
   
   const handleRouteChangeComplete = () => {
      document.documentElement.style.scrollBehavior = refScroll.current;
   }

   router.events.on('routeChangeStart', handleRouteChangeStart);
   router.events.on('routeChangeComplete', handleRouteChangeComplete);

   return () => {
      router.events.off('routeChangeStart', handleRouteChangeStart);
      router.events.off('routeChangeComplete', handleRouteChangeComplete);
   };
}, [])

Revert to previous code at the end of onHeadCommit

if (input.scroll) {
   window.scrollTo(input.scroll.x, input.scroll.y);
}

Probably the identical logic is also necessary in the file (not tested as this is probably due to Next's new layout system)

  • next/client/components/layout-router -> component InnerLayoutRouter

Furthermore, the function handleSmoothScroll, which is called in scrollToHash (file next/shared/lib/router.ts) is not needed.

What do you think, I can provide a pr with those changes?

Thx!

@wyattjoh
Copy link
Member

Hi @gurkerl83! I've attempted to reproduce the issue you described with the scrolling behavior, but I'm having issues with finding any issue.

You can see this reproduction over here https://nextjs-scroll-repro.vercel.app/ and can see that between the navigations, there is no issues.

@gurkerl83
Copy link
Contributor Author

gurkerl83 commented Dec 1, 2022

@wyattjoh The issue is when you have two pages (routes), both huge in space.

Obviously, smooth scrolling on one page is not a problem and it works.

But when you are scrolled very far from the top of page A and then execute a route change to the page B (also very long) during navigation, the page scrolls all the way up from the previous scroll position of Page A to the top (newly loaded page B); this has to be immediate, with "auto" activated until the page loading is finished.

The vertical amount of space of a page and where the scroll position is when an navigation attempt is made is really the key here to reproduce the issue.

I hope this makes sense.

Thx!

@wyattjoh
Copy link
Member

wyattjoh commented Dec 1, 2022

Is it possible to fork my reproduction to create an appropriate reproduction of the issue?

@gurkerl83
Copy link
Contributor Author

I think i see this already in your reproducer. Make the sections larger, scroll down in one page and execute a route change to the other page (not a hash change). You will see a scroll during navigation to the top and this is what needs to be immediate.

@gurkerl83
Copy link
Contributor Author

gurkerl83 commented Dec 1, 2022

@wyattjoh Here is a video with your reproducer in action illustration the problem. I adjusted the height of the sections to make it clear. Imagine when you have larger pages such as blog posts or a e-commerce pages showing dozen of items, changing a route causes a smooth transition to the top, which is really annoying, and this has to be immediate.

Screen.Recording.2022-12-01.at.23.57.02.mov

Thx!

@jankaifer jankaifer self-assigned this Dec 3, 2022
@jankaifer
Copy link
Contributor

jankaifer commented Dec 3, 2022

I did the following experiments: repro
Very basic scrollTo(0) on one long empty page. No scroll-behavior in css.

These are smooth:

document.documentElement.style.scrollBehavior = "smooth"
await sleep(0);
document.documentElement.style.scrollBehavior = "auto"
window.scrollTo(0, 0)
document.documentElement.style.scrollBehavior = "smooth"
await sleep(0);
document.documentElement.style.scrollBehavior = "auto"
await sleep(0);
window.scrollTo(0, 0)

These are instant:

document.documentElement.style.scrollBehavior = "smooth"
document.documentElement.style.scrollBehavior = "auto"
window.scrollTo(0, 0)
document.documentElement.style.scrollBehavior = "smooth"
await sleep(0);
document.documentElement.style.scrollBehavior = "auto"
await sleep(10);
window.scrollTo(0, 0)

Adding scrollBehavior = "smooth" after scrollTo doesn't change the behavior.

It's not claar to me why the following doesn't scroll instantly:

document.documentElement.style.scrollBehavior = "smooth"
await sleep(0);
window.scrollTo({
  top: 0,
  behavior: 'auto'
});

Observations:

  • scrollTo only cares about the css styles when it is fired
  • "smooths" - needs setTimeout(0)" to kick in
  • "auto" - needs at least setTimeout(2) to kick in (setTimeout(1) works only sometimes)

I did all the test in chrome.

@jankaifer
Copy link
Contributor

Weird this will be instant:

html {
  scroll-behavior: smooth;
}
document.documentElement.style.scrollBehavior = "auto"
await sleep(0);
window.scrollTo(0, 0)

And this one seems to be instant even with value set in css:

document.documentElement.style.scrollBehavior = "auto"
await sleep(0);
document.documentElement.style.scrollBehavior = "smooth"
await sleep(0);
window.scrollTo(0, 0)
document.documentElement.style.scrollBehavior = originalValue;

It seems that when setting from JS it needs setTimeout(0) to start using the new CSS and it needs setTimeout(1+) when setting it again.

@jankaifer
Copy link
Contributor

jankaifer commented Dec 3, 2022

The problem might be that scrollTo is not triggering reflow (but css tricks says it should? I haven't found proper docs).
This scrolls instantly:

html {
  scroll-behavior: smooth;
}
document.documentElement.style.scrollBehavior = "auto"
document.documentElement.getClientRects() // triggers reflow
window.scrollTo(0)

@jankaifer
Copy link
Contributor

jankaifer commented Dec 3, 2022

Forcing reflow with getClientRects() in next.js source code fixes the issue in @wyattjoh reproduction.
But it's a bit hacky. Created a PR with those changes: #43673

Is there something wrong with my observations?

@gurkerl83
Copy link
Contributor Author

gurkerl83 commented Dec 3, 2022

@jankaifer I think you have to look at the changes made in #40642 also mentioned in the very first comment of this issue to make sense of it. To set „auto“, then scroll and then reset back to „smooth“ does not force scrolling to do it in „auto“ or immediate mode, these changes execute in a synchronized manner (resetting to smooth does not wait for scroll to finish).

Looking at your PR, for me it is not clear why getClientRects triggers a reflow, is this documented somewhere? When it would be a repo of mine, I would rather resolve it directly, instead if embedding API calls which might provide this kind of side effect.

Anyway, thx for spending time looking at the problem! I think it’s time for a Next developer to respond to these findings, to get the problem resolved, hopefully in one of the next minor releases.

@jankaifer
Copy link
Contributor

jankaifer commented Dec 4, 2022

I've read the whole thread and I've seen that commit.
I forgot to include the most basic example in my experiments, it's this one:

html {
  scroll-behavior: smooth;
}
document.documentElement.style.scrollBehavior = "auto"
window.scrollTo(0)

This will be smooth. And triggering reflow before scrollTo makes it instant. And setting behavior after scrollTo has no effect.
That's why I think that your hypothesis with "auto needs to be set for the whole animation" is not right.

Forcing reflow is a little-known hack that is sometimes necessary. For example for triggering FLIP animations. I don't know about any proper docs except this empty mdn page.

@wyattjoh
Copy link
Member

wyattjoh commented Dec 6, 2022

My testing only found the issue in Chrome. Firefox and Safari correctly did not have this reflow bug.

I've tested @jankaifer's PR (seen here) and it looks like that fixes the problem. I'm going to recommend we merge that PR as it doesn't depend on router events which are not available in the new router in the app directory.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 6, 2023

This closed issue has been automatically locked because it had no new activity for a month. If you are running into a similar issue, please create a new issue with the steps to reproduce. Thank you.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 6, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
bug Issue was opened via the bug report template.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants