From e00f66d8c5496797768da7dd7d6cf5312f396b27 Mon Sep 17 00:00:00 2001 From: inokawa <48897392+inokawa@users.noreply.github.com> Date: Wed, 30 Aug 2023 22:01:13 +0900 Subject: [PATCH] Defer applying jump to scroll position in iOS Safari --- playwright.config.ts | 8 ++--- src/core/environment.ts | 9 ++++- src/core/store.ts | 49 ++++++++++++++++++++++---- src/react/ListItem.tsx | 11 ++++-- src/react/VGrid.tsx | 16 ++++----- src/react/VList.tsx | 13 +++++-- src/react/WVList.tsx | 2 +- src/react/useIsomorphicLayoutEffect.ts | 4 +-- 8 files changed, 83 insertions(+), 29 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 0a1d15a36..6ccdd68c5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -52,10 +52,10 @@ export default defineConfig({ // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 13"] }, + }, /* Test against branded browsers. */ // { diff --git a/src/core/environment.ts b/src/core/environment.ts index d25f6e0b3..ae41abc71 100644 --- a/src/core/environment.ts +++ b/src/core/environment.ts @@ -1,5 +1,7 @@ import { once } from "./utils"; +export const isBrowser = typeof window !== "undefined"; + // The scroll position may be negative value in rtl direction. // // left right result @@ -7,7 +9,7 @@ import { once } from "./utils"; // 0 100 false probably Chrome earlier than v85 // https://github.com/othree/jquery.rtl-scroll-type export const hasNegativeOffsetInRtl = /*#__PURE__*/ once( - (scrollable: HTMLElement) => { + (scrollable: HTMLElement): boolean => { const key = "scrollLeft"; const prev = scrollable[key]; scrollable[key] = 1; @@ -17,3 +19,8 @@ export const hasNegativeOffsetInRtl = /*#__PURE__*/ once( return isNegative; } ); + +// Currently, all browsers on iOS/iPadOS are WebKit. +export const isIOSWebKit = /*#__PURE__*/ once((): boolean => { + return isBrowser ? /iP(hone|od|ad)/.test(navigator.userAgent) : false; +}); diff --git a/src/core/store.ts b/src/core/store.ts index cbd0fb620..52e0cc409 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -11,6 +11,7 @@ import { updateCacheLength, computeRange, } from "./cache"; +import { isIOSWebKit } from "./environment"; import type { CacheSnapshot, Writeable } from "./types"; import { abs, clamp, max, min } from "./utils"; @@ -103,12 +104,14 @@ export const createVirtualStore = ( let scrollOffset = 0; let jumpCount = 0; let jump: ScrollJump = 0; + let pendingJump: ScrollJump = 0; let _scrollDirection: ScrollDirection = SCROLL_IDLE; let _isShifting = false; let _isManualScrolling = false; let _resized = false; let _prevRange: ItemsRange = [0, initialItemCount]; + const shouldDeferJump = isIOSWebKit(); const subscribers = new Set<[number, Subscriber]>(); const getScrollSize = (): number => computeTotalSize(cache as Writeable); @@ -118,6 +121,14 @@ export const createVirtualStore = ( // Scroll offset may exceed min or max especially in Safari's elastic scrolling. return clamp(value, 0, getScrollOffsetMax()); }; + const applyJump = (j: ScrollJump) => { + if (shouldDeferJump && _scrollDirection !== SCROLL_IDLE) { + pendingJump += j; + } else { + jump += j; + jumpCount++; + } + }; const updateScrollDirection = (dir: ScrollDirection): boolean => { const prev = _scrollDirection; _scrollDirection = dir; @@ -133,7 +144,7 @@ export const createVirtualStore = ( const [prevStartIndex, prevEndIndex] = _prevRange; const [start, end] = computeRange( cache as Writeable, - scrollOffset, + scrollOffset + pendingJump, prevStartIndex, viewportSize ); @@ -155,7 +166,8 @@ export const createVirtualStore = ( return hasUnmeasuredItemsInRange(cache, startIndex, endIndex); }, _getItemOffset(index) { - const offset = computeStartOffset(cache as Writeable, index); + const offset = + computeStartOffset(cache as Writeable, index) - pendingJump; if (isReverse) { return offset + max(0, viewportSize - getScrollSize()); } @@ -196,6 +208,7 @@ export const createVirtualStore = ( }; }, _update(type, payload): void { + let shouldFlushJump: boolean | undefined; let shouldSync: boolean | undefined; let mutated = 0; @@ -237,8 +250,7 @@ export const createVirtualStore = ( // Do nothing } if (diff) { - jump = diff; - jumpCount++; + applyJump(diff); mutated += UPDATE_JUMP; } @@ -281,9 +293,8 @@ export const createVirtualStore = ( true ); const diff = isRemove ? -min(shift, distanceToEnd) : shift; - jump += diff; + applyJump(diff); scrollOffset = clampScrollOffset(scrollOffset + diff); - jumpCount++; mutated = UPDATE_SCROLL + UPDATE_JUMP; _isShifting = true; @@ -301,6 +312,7 @@ export const createVirtualStore = ( if (type === ACTION_SCROLL) { const delta = payload - scrollOffset; + const distance = abs(delta); // Scrolling after resizing will be caused by jump compensation const isJustResized = _resized; _resized = false; @@ -313,15 +325,28 @@ export const createVirtualStore = ( !_isManualScrolling ) { if (updateScrollDirection(delta < 0 ? SCROLL_UP : SCROLL_DOWN)) { + // shouldFlushJump = true; mutated += UPDATE_SCROLL_DIRECTION; } } + if (distance > viewportSize * 2) { + // When scrolled a lot, we would not recognize the amount of jump so we can discard them. + // TODO reset pending + // jump = 0; + } else if ( + payload - max(jump, 0) <= 0 || + payload - min(jump, 0) >= getScrollOffsetMax() + ) { + // Flush if almost reached to start or end + shouldFlushJump = true; + } + // Ignore manual scroll because it may be called in useEffect/useLayoutEffect and cause the warn below. // Warning: flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task. // // Update synchronously if scrolled a lot - shouldSync = abs(delta) > viewportSize; + shouldSync = distance > viewportSize; mutated += UPDATE_SCROLL_WITH_EVENT; @@ -336,6 +361,7 @@ export const createVirtualStore = ( } case ACTION_SCROLL_END: { if (updateScrollDirection(SCROLL_IDLE)) { + shouldFlushJump = true; mutated = UPDATE_SCROLL_DIRECTION; } _isShifting = _isManualScrolling = false; @@ -348,6 +374,15 @@ export const createVirtualStore = ( } if (mutated) { + if (shouldFlushJump && pendingJump) { + _resized = true; + _isShifting = true; + jump += pendingJump; + pendingJump = 0; + jumpCount++; + mutated += UPDATE_JUMP; + } + const update = () => { subscribers.forEach(([target, cb]) => { // Early return to skip React's computation diff --git a/src/react/ListItem.tsx b/src/react/ListItem.tsx index 7c08c0752..73bbfb322 100644 --- a/src/react/ListItem.tsx +++ b/src/react/ListItem.tsx @@ -6,7 +6,12 @@ import { ReactElement, ReactNode, } from "react"; -import { UPDATE_SIZE, VirtualStore } from "../core/store"; +import { + UPDATE_JUMP, + UPDATE_SCROLL, + UPDATE_SIZE, + VirtualStore, +} from "../core/store"; import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect"; import { useSelector } from "./useSelector"; import { ListResizer } from "../core/resizer"; @@ -49,13 +54,13 @@ export const ListItem = memo( const offset = useSelector( store, () => store._getItemOffset(index), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const hide = useSelector( store, () => store._isUnmeasuredItem(index), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); diff --git a/src/react/VGrid.tsx b/src/react/VGrid.tsx index 912722adf..24e30b05a 100644 --- a/src/react/VGrid.tsx +++ b/src/react/VGrid.tsx @@ -83,37 +83,37 @@ const Cell = memo( const top = useSelector( vStore, () => vStore._getItemOffset(rowIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const left = useSelector( hStore, () => hStore._getItemOffset(colIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const vHide = useSelector( vStore, () => vStore._isUnmeasuredItem(rowIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const hHide = useSelector( hStore, () => hStore._isUnmeasuredItem(colIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const height = useSelector( vStore, () => vStore._getItemSize(rowIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); const width = useSelector( hStore, () => hStore._getItemSize(colIndex), - UPDATE_SIZE, + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, true ); @@ -314,12 +314,12 @@ export const VGrid = forwardRef( const [startRowIndex, endRowIndex] = useSelector( vStore, vStore._getRange, - UPDATE_SCROLL + UPDATE_SIZE + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP ); const [startColIndex, endColIndex] = useSelector( hStore, hStore._getRange, - UPDATE_SCROLL + UPDATE_SIZE + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP ); const vScrollDirection = useSelector( vStore, diff --git a/src/react/VList.tsx b/src/react/VList.tsx index 74441a350..cdc1d8aa3 100644 --- a/src/react/VList.tsx +++ b/src/react/VList.tsx @@ -233,14 +233,21 @@ export const VList = forwardRef( const [startIndex, endIndex] = useSelector( store, store._getRange, - UPDATE_SCROLL + UPDATE_SIZE + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP, + true ); const scrollDirection = useSelector( store, store._getScrollDirection, - UPDATE_SCROLL_DIRECTION + UPDATE_SCROLL_DIRECTION, + true + ); + const jumpCount = useSelector( + store, + store._getJumpCount, + UPDATE_JUMP, + true ); - const jumpCount = useSelector(store, store._getJumpCount, UPDATE_JUMP); const scrollSize = useSelector( store, store._getCorrectedScrollSize, diff --git a/src/react/WVList.tsx b/src/react/WVList.tsx index 18789172b..d32234ec5 100644 --- a/src/react/WVList.tsx +++ b/src/react/WVList.tsx @@ -179,7 +179,7 @@ export const WVList = forwardRef( const [startIndex, endIndex] = useSelector( store, store._getRange, - UPDATE_SCROLL + UPDATE_SIZE + UPDATE_SCROLL + UPDATE_SIZE + UPDATE_JUMP ); const scrollDirection = useSelector( store, diff --git a/src/react/useIsomorphicLayoutEffect.ts b/src/react/useIsomorphicLayoutEffect.ts index 4985c2249..03d6ff2fa 100644 --- a/src/react/useIsomorphicLayoutEffect.ts +++ b/src/react/useIsomorphicLayoutEffect.ts @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect } from "react"; +import { isBrowser } from "../core/environment"; // https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85 -export const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; +export const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;