Skip to content

Commit

Permalink
fix(#8625): smooth scrolling in SPA mode on iOS (#10235)
Browse files Browse the repository at this point in the history
* fix(#8625): smooth scrolling in SPA mode on iOS

* perf(router): run cb every 200ms only when scolling

* refactor(router): suggested changes and fixes

Suggested changes:
- change interval time from 200 to 50ms
- initialize `last*` vars together with the call to `setInterval()`
- clear interval when scroll positions stop changing, independent of
  history state

Additional changes:
- remove unused `throttle()` function
- move guarded block to inside `onScrollEnd()` since using history
  navigation will trigger our "popstate" callback and fire additional
  "scroll" and "scrollend" events, causing redundant expensive calls to
  `replaceState()`

* adds changeset

---------

Co-authored-by: Martin Trapp <94928215+martrapp@users.noreply.github.com>
  • Loading branch information
sanman1k98 and martrapp authored Mar 3, 2024
1 parent 2db9031 commit 4bc360c
Show file tree
Hide file tree
Showing 2 changed files with 50 additions and 28 deletions.
5 changes: 5 additions & 0 deletions .changeset/purple-tips-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"astro": patch
---

Fixes jerky scrolling on IOS when using view transitions.
73 changes: 45 additions & 28 deletions packages/astro/src/transitions/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,28 +90,6 @@ if (inBrowser) {
}
}

const throttle = (cb: (...args: any[]) => any, delay: number) => {
let wait = false;
// During the waiting time additional events are lost.
// So repeat the callback at the end if we have swallowed events.
let onceMore = false;
return (...args: any[]) => {
if (wait) {
onceMore = true;
return;
}
cb(...args);
wait = true;
setTimeout(() => {
if (onceMore) {
onceMore = false;
cb(...args);
}
wait = false;
}, delay);
};
};

// returns the contents of the page or null if the router can't deal with it.
async function fetchHTML(
href: string,
Expand Down Expand Up @@ -625,10 +603,15 @@ function onPopState(ev: PopStateEvent) {
transition(direction, originalLocation, new URL(location.href), {}, state);
}

// There's not a good way to record scroll position before a back button.
// So the way we do it is by listening to scrollend if supported, and if not continuously record the scroll position.
const onScroll = () => {
updateScrollPosition({ scrollX, scrollY });
const onScrollEnd = () => {
// NOTE: our "popstate" event handler may call `pushState()` or
// `replaceState()` and then `scrollTo()`, which will fire "scroll" and
// "scrollend" events. To avoid redundant work and expensive calls to
// `replaceState()`, we simply check that the values are different before
// updating.
if (scrollX !== history.state.scrollX || scrollY !== history.state.scrollY) {
updateScrollPosition({ scrollX, scrollY });
}
};

// initialization
Expand All @@ -637,8 +620,42 @@ if (inBrowser) {
originalLocation = new URL(location.href);
addEventListener('popstate', onPopState);
addEventListener('load', onPageLoad);
if ('onscrollend' in window) addEventListener('scrollend', onScroll);
else addEventListener('scroll', throttle(onScroll, 350), { passive: true });
// There's not a good way to record scroll position before a history back
// navigation, so we will record it when the user has stopped scrolling.
if ('onscrollend' in window) addEventListener('scrollend', onScrollEnd);
else {
// Keep track of state between intervals
let intervalId: number | undefined, lastY: number, lastX: number, lastIndex: State["index"];
const scrollInterval = () => {
// Check the index to see if a popstate event was fired
if (lastIndex !== history.state?.index) {
clearInterval(intervalId);
intervalId = undefined;
return;
}
// Check if the user stopped scrolling
if (lastY === scrollY && lastX === scrollX) {
// Cancel the interval and update scroll positions
clearInterval(intervalId);
intervalId = undefined;
onScrollEnd();
return;
} else {
// Update vars with current positions
lastY = scrollY, lastX = scrollX;
}
}
// We can't know when or how often scroll events fire, so we'll just use them to start intervals
addEventListener(
"scroll",
() => {
if (intervalId !== undefined) return;
lastIndex = history.state.index, lastY = scrollY, lastX = scrollX;
intervalId = window.setInterval(scrollInterval, 50);
},
{ passive: true }
);
};
}
for (const script of document.scripts) {
script.dataset.astroExec = '';
Expand Down

0 comments on commit 4bc360c

Please sign in to comment.