From e7f077c834edcc2ac97521594dbe82f3bd72a095 Mon Sep 17 00:00:00 2001 From: inokawa <48897392+inokawa@users.noreply.github.com> Date: Sun, 23 Jul 2023 14:00:09 +0900 Subject: [PATCH 1/2] Optimize state update --- src/core/store.ts | 34 +++++++++++++++++++++++----------- src/react/useSelector.ts | 21 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/core/store.ts b/src/core/store.ts index 3ac989481..006f5918c 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -54,6 +54,10 @@ type Actions = type Subscriber = (sync?: boolean) => void; +export const UPDATE_SCROLL = 0b001; +export const UPDATE_SIZE = 0b010; +export const UPDATE_JUMP = 0b100; + export type VirtualStore = { _getRange(): ItemsRange; _isUnmeasuredItem(index: number): boolean; @@ -70,7 +74,7 @@ export type VirtualStore = { _flushJump(): ScrollJump | undefined; _getItemIndexForScrollTo(offset: number): number; _waitForScrollDestinationItemsMeasured(): Promise; - _subscribe(cb: Subscriber): () => void; + _subscribe(target: number, cb: Subscriber): () => void; _update(...action: Actions): void; _getScrollDirection(): ScrollDirection; _updateCacheLength(length: number): void; @@ -96,7 +100,7 @@ export const createVirtualStore = ( let _prevRange: ItemsRange = [0, initialItemCount]; let _scrollToQueue: [() => void, () => void] | undefined; - const subscribers = new Set(); + const subscribers = new Set<[number, Subscriber]>(); const getScrollSize = (): number => computeTotalSize(cache as Writeable); const getScrollOffsetMax = () => getScrollSize() - viewportSize; @@ -206,16 +210,17 @@ export const createVirtualStore = ( timeout(resolveQueue, 250); }); }, - _subscribe(cb) { - subscribers.add(cb); + _subscribe(target, cb) { + const sub: [number, Subscriber] = [target, cb]; + subscribers.add(sub); return () => { - subscribers.delete(cb); + subscribers.delete(sub); }; }, _update(type, payload): void { let shouldSync: boolean | undefined; let updatedScrollState: boolean | undefined; - let mutated = false; + let mutated = 0; switch (type) { case ACTION_ITEM_RESIZE: { @@ -255,6 +260,7 @@ export const createVirtualStore = ( if (diff) { jump = diff; jumpCount++; + mutated += UPDATE_JUMP; } // Update item sizes @@ -274,12 +280,14 @@ export const createVirtualStore = ( ) { estimateDefaultItemSize(cache as Writeable); } - - _resized = shouldSync = mutated = true; + mutated += UPDATE_SIZE; + _resized = shouldSync = true; break; } case ACTION_WINDOW_RESIZE: { - mutated = viewportSize !== payload; + if (viewportSize !== payload) { + mutated = UPDATE_SIZE; + } viewportSize = payload; break; } @@ -313,7 +321,7 @@ export const createVirtualStore = ( // Scroll offset may exceed min or max especially in Safari's elastic scrolling. scrollOffset = max(0, min(getScrollOffsetMax(), payload)); - mutated = true; + mutated = UPDATE_SCROLL; break; } case ACTION_SCROLL_END: { @@ -325,7 +333,11 @@ export const createVirtualStore = ( } if (mutated) { - subscribers.forEach((cb) => { + subscribers.forEach(([target, cb]) => { + // Early return to skip React's computation + if (!(mutated & target)) { + return; + } cb(shouldSync); }); diff --git a/src/react/useSelector.ts b/src/react/useSelector.ts index 6f036a41b..9d9226441 100644 --- a/src/react/useSelector.ts +++ b/src/react/useSelector.ts @@ -2,7 +2,12 @@ import { useLayoutEffect, useState } from "react"; import { flushSync } from "react-dom"; import { useRefWithUpdate } from "./useRefWithUpdate"; import { refKey } from "./utils"; -import { VirtualStore } from "../core/store"; +import { + UPDATE_JUMP, + UPDATE_SCROLL, + UPDATE_SIZE, + VirtualStore, +} from "../core/store"; export const useSelector = ( store: VirtualStore, @@ -13,10 +18,22 @@ export const useSelector = ( const getter = useRefWithUpdate(getSnapShot); useLayoutEffect(() => { + let target: number; + if (getSnapShot === store._getRange) { + target = UPDATE_SCROLL + UPDATE_SIZE; + } else if (getSnapShot === store._getCorrectedScrollSize) { + target = UPDATE_SIZE; + } else if (getSnapShot === store._getJumpCount) { + target = UPDATE_JUMP; + } else { + // Others will be item subscribers + target = UPDATE_SIZE; + } + const update = () => { setState(() => getter[refKey]()); }; - return store._subscribe((sync) => { + return store._subscribe(target, (sync) => { // TODO batch flushSync to remove shouldGetLatest argument if possible // https://github.com/facebook/react/issues/25191 if (sync) { From 723a200af0616254e5d404ac8f284ddbe95a6839 Mon Sep 17 00:00:00 2001 From: inokawa <48897392+inokawa@users.noreply.github.com> Date: Sun, 23 Jul 2023 16:30:22 +0900 Subject: [PATCH 2/2] Remove waitForScrollDestinationItemsMeasured from store --- src/core/scroller.ts | 33 ++++++++++++++++++++++++++++++--- src/core/store.ts | 31 +------------------------------ 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/src/core/scroller.ts b/src/core/scroller.ts index 470c597f0..8c83f4072 100644 --- a/src/core/scroller.ts +++ b/src/core/scroller.ts @@ -6,8 +6,9 @@ import { VirtualStore, SCROLL_IDLE, ACTION_SCROLL_END, + UPDATE_SIZE, } from "./store"; -import { debounce, throttle, max, min } from "./utils"; +import { debounce, throttle, max, min, timeout } from "./utils"; // Infer scroll state also from wheel events // Sometimes scroll events do not fire when frame dropped even if the visual have been already scrolled @@ -51,6 +52,7 @@ export const createScroller = ( isRtl: boolean ): Scroller => { let rootElement: HTMLElement | undefined; + let scrollToQueue: [() => void, () => void] | undefined; const scrollToKey = isHorizontal ? "scrollLeft" : "scrollTop"; const getActualScrollSize = (): number => { @@ -93,12 +95,37 @@ export const createScroller = ( break; } + if (scrollToQueue) { + // Cancel waiting scrollTo + scrollToQueue[1](); + } + + // Wait for the scroll destination items to be measured. + const unsubscribe = store._subscribe(UPDATE_SIZE, () => { + scrollToQueue && scrollToQueue[0](); + }); try { - // Wait for the scroll destination items to be measured. - await store._waitForScrollDestinationItemsMeasured(); + // The measurement will be done asynchronously and the timing is not predictable so we use promise. + // For example, ResizeObserver may not fire when window is not visible. + await new Promise((resolve, reject) => { + let resolved = false; + + const resolveQueue = () => { + if (resolved) return; + resolved = true; + resolve(); + scrollToQueue = undefined; + }; + scrollToQueue = [resolveQueue, reject]; + + // In some specific situation with VGrid, the promise never resolved so we cancel it if timed out. + timeout(resolveQueue, 250); + }); } catch (e) { // canceled return; + } finally { + unsubscribe(); } } diff --git a/src/core/store.ts b/src/core/store.ts index 006f5918c..2f2dba96c 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -12,7 +12,7 @@ import { estimateDefaultItemSize, } from "./cache"; import type { Writeable } from "./types"; -import { abs, exists, max, min, timeout } from "./utils"; +import { abs, exists, max, min } from "./utils"; type ItemJump = Readonly<[sizeDiff: number, index: number]>; export type ScrollJump = Readonly; @@ -73,7 +73,6 @@ export type VirtualStore = { _getJumpCount(): number; _flushJump(): ScrollJump | undefined; _getItemIndexForScrollTo(offset: number): number; - _waitForScrollDestinationItemsMeasured(): Promise; _subscribe(target: number, cb: Subscriber): () => void; _update(...action: Actions): void; _getScrollDirection(): ScrollDirection; @@ -98,7 +97,6 @@ export const createVirtualStore = ( let _scrollDirection: ScrollDirection = SCROLL_IDLE; let _resized = false; let _prevRange: ItemsRange = [0, initialItemCount]; - let _scrollToQueue: [() => void, () => void] | undefined; const subscribers = new Set<[number, Subscriber]>(); const getScrollSize = (): number => @@ -188,28 +186,6 @@ export const createVirtualStore = ( _getItemIndexForScrollTo(offset) { return findStartIndexWithOffset(cache, offset, 0, 0); }, - _waitForScrollDestinationItemsMeasured() { - if (_scrollToQueue) { - // Cancel waiting scrollTo - _scrollToQueue[1](); - } - // The measurement will be done asynchronously and the timing is not predictable so we use promise. - // For example, ResizeObserver may not fire when window is not visible. - return new Promise((resolve, reject) => { - let resolved: boolean | undefined = false; - - const resolveQueue = () => { - if (resolved) return; - resolved = true; - resolve(); - _scrollToQueue = undefined; - }; - _scrollToQueue = [resolveQueue, reject]; - - // In some specific situation with VGrid, the promise never resolved so we cancel it if timed out. - timeout(resolveQueue, 250); - }); - }, _subscribe(target, cb) { const sub: [number, Subscriber] = [target, cb]; subscribers.add(sub); @@ -345,11 +321,6 @@ export const createVirtualStore = ( onScrollOffsetChange && onScrollOffsetChange(scrollOffset); } } - if (_scrollToQueue) { - if (type === ACTION_ITEM_RESIZE) { - _scrollToQueue[0](); - } - } if (exists(updatedScrollState)) { onScrollStateChange(updatedScrollState); }