Skip to content

Commit

Permalink
Merge pull request #135 from inokawa/bit
Browse files Browse the repository at this point in the history
Optimize state update
  • Loading branch information
inokawa committed Jul 23, 2023
2 parents 5fb2c28 + 723a200 commit 5113de5
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 46 deletions.
33 changes: 30 additions & 3 deletions src/core/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<void>((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();
}
}

Expand Down
65 changes: 24 additions & 41 deletions src/core/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
Expand Down Expand Up @@ -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;
Expand All @@ -69,8 +73,7 @@ export type VirtualStore = {
_getJumpCount(): number;
_flushJump(): ScrollJump | undefined;
_getItemIndexForScrollTo(offset: number): number;
_waitForScrollDestinationItemsMeasured(): Promise<void>;
_subscribe(cb: Subscriber): () => void;
_subscribe(target: number, cb: Subscriber): () => void;
_update(...action: Actions): void;
_getScrollDirection(): ScrollDirection;
_updateCacheLength(length: number): void;
Expand All @@ -94,9 +97,8 @@ 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<Subscriber>();
const subscribers = new Set<[number, Subscriber]>();
const getScrollSize = (): number =>
computeTotalSize(cache as Writeable<Cache>);
const getScrollOffsetMax = () => getScrollSize() - viewportSize;
Expand Down Expand Up @@ -184,38 +186,17 @@ 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(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: {
Expand Down Expand Up @@ -255,6 +236,7 @@ export const createVirtualStore = (
if (diff) {
jump = diff;
jumpCount++;
mutated += UPDATE_JUMP;
}

// Update item sizes
Expand All @@ -274,12 +256,14 @@ export const createVirtualStore = (
) {
estimateDefaultItemSize(cache as Writeable<Cache>);
}

_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;
}
Expand Down Expand Up @@ -313,7 +297,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: {
Expand All @@ -325,19 +309,18 @@ 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);
});

if (type === ACTION_SCROLL) {
onScrollOffsetChange && onScrollOffsetChange(scrollOffset);
}
}
if (_scrollToQueue) {
if (type === ACTION_ITEM_RESIZE) {
_scrollToQueue[0]();
}
}
if (exists(updatedScrollState)) {
onScrollStateChange(updatedScrollState);
}
Expand Down
21 changes: 19 additions & 2 deletions src/react/useSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(
store: VirtualStore,
Expand All @@ -13,10 +18,22 @@ export const useSelector = <T>(
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) {
Expand Down

0 comments on commit 5113de5

Please sign in to comment.