diff --git a/.size-limit.json b/.size-limit.json index 58bd4d38b..f1425d64d 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -3,7 +3,7 @@ "name": "Total", "path": "lib/index.mjs", "import": "*", - "limit": "5.25 kB" + "limit": "5.5 kB" }, { "name": "VList", 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 815379b5a..dab716a36 100644 --- a/src/core/environment.ts +++ b/src/core/environment.ts @@ -24,3 +24,8 @@ export const isRTLDocument = /*#__PURE__*/ once((): boolean => { // TODO support SSR in rtl return isBrowser ? computeStyle(document.body).direction === "rtl" : false; }); + +// Currently, all browsers on iOS/iPadOS are WebKit, including WebView. +export const isIOSWebKit = /*#__PURE__*/ once((): boolean => { + return isBrowser ? /iP(hone|od|ad)/.test(navigator.userAgent) : false; +}); diff --git a/src/core/scroller.ts b/src/core/scroller.ts index 29afb9a4c..0efdea270 100644 --- a/src/core/scroller.ts +++ b/src/core/scroller.ts @@ -137,11 +137,17 @@ export const createScroller = ( _initRoot(root) { rootElement = root; + let touching = false; + const syncViewportToScrollPosition = () => { store._update(ACTION_SCROLL, normalizeOffset(root[scrollToKey])); }; const onScrollStopped = debounce(() => { + if (touching) { + onScrollStopped(); + return; + } // Check scroll position once just after scrolling stopped syncViewportToScrollPosition(); store._update(ACTION_SCROLL_END); @@ -154,12 +160,23 @@ export const createScroller = ( const onWheel = createOnWheel(store, isHorizontal, onScrollStopped); + const onTouchStart = () => { + touching = true; + }; + const onTouchEnd = () => { + touching = false; + }; + root.addEventListener("scroll", onScroll); root.addEventListener("wheel", onWheel, { passive: true }); + root.addEventListener("touchstart", onTouchStart, { passive: true }); + root.addEventListener("touchend", onTouchEnd, { passive: true }); return () => { root.removeEventListener("scroll", onScroll); root.removeEventListener("wheel", onWheel); + root.removeEventListener("touchstart", onTouchStart); + root.removeEventListener("touchend", onTouchEnd); onScrollStopped._cancel(); }; }, diff --git a/src/core/store.ts b/src/core/store.ts index cbd0fb620..9c169315e 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 shouldFlushPendingJump: 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; @@ -317,11 +329,22 @@ export const createVirtualStore = ( } } + if ( + pendingJump && + ((_scrollDirection === SCROLL_UP && + payload - max(pendingJump, 0) <= 0) || + (_scrollDirection === SCROLL_DOWN && + payload - min(pendingJump, 0) >= getScrollOffsetMax())) + ) { + // Flush if almost reached to start or end + shouldFlushPendingJump = 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 +359,7 @@ export const createVirtualStore = ( } case ACTION_SCROLL_END: { if (updateScrollDirection(SCROLL_IDLE)) { + shouldFlushPendingJump = true; mutated = UPDATE_SCROLL_DIRECTION; } _isShifting = _isManualScrolling = false; @@ -348,6 +372,15 @@ export const createVirtualStore = ( } if (mutated) { + if (shouldFlushPendingJump && 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 b40c60271..5578f699d 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"; @@ -48,13 +53,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 157ddaff8..624b00fb9 100644 --- a/src/react/VGrid.tsx +++ b/src/react/VGrid.tsx @@ -82,37 +82,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 ); @@ -304,12 +304,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 b4f12e048..4a383604f 100644 --- a/src/react/VList.tsx +++ b/src/react/VList.tsx @@ -226,14 +226,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 c6c517cf3..556650375 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,