Skip to content

Commit 48b9719

Browse files
committed
Batch updates and reduce array creation
1 parent 7121ef0 commit 48b9719

14 files changed

Lines changed: 111 additions & 116 deletions

e2e/VList.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ test.describe("smoke", () => {
7979
// check if last is displayed
8080
const last = component.getByText("999", { exact: true });
8181
await expect(last).toBeVisible();
82-
expect(await relativeBottom(component, last)).toEqual(0);
82+
expectInRange(await relativeBottom(component, last), { min: 0, max: 0.5 });
8383
});
8484

8585
test("display: none", async ({ page }) => {

e2e/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const expectInRange = (
3636
) => {
3737
// sometimes it may not be 0 because of sub pixel value
3838
expect(value).toBeGreaterThanOrEqual(min);
39-
expect(value).toBeLessThan(max);
39+
expect(value).toBeLessThanOrEqual(max);
4040
};
4141

4242
export const approxymate = (v: number): number => Math.round(v / 100) * 100;

src/core/resizer.ts

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export const createResizer = (
5252
const mountedIndexes = new WeakMap<Element, number>();
5353

5454
const resizeObserver = createResizeObserver((entries) => {
55-
const resizes: ItemResize[] = [];
5655
for (const { target, contentRect } of entries) {
5756
// Skip zero-sized rects that may be observed under `display: none` style
5857
if (!(target as HTMLElement).offsetParent) continue;
@@ -62,14 +61,10 @@ export const createResizer = (
6261
} else {
6362
const index = mountedIndexes.get(target);
6463
if (index != NULL) {
65-
resizes.push([index, contentRect[sizeKey]]);
64+
store.$update(ACTION_ITEM_RESIZE, [index, contentRect[sizeKey]]);
6665
}
6766
}
6867
}
69-
70-
if (resizes.length) {
71-
store.$update(ACTION_ITEM_RESIZE, resizes);
72-
}
7368
});
7469

7570
return {
@@ -106,20 +101,15 @@ export const createWindowResizer = (
106101
const mountedIndexes = new WeakMap<Element, number>();
107102

108103
const resizeObserver = createResizeObserver((entries) => {
109-
const resizes: ItemResize[] = [];
110104
for (const { target, contentRect } of entries) {
111105
// Skip zero-sized rects that may be observed under `display: none` style
112106
if (!(target as HTMLElement).offsetParent) continue;
113107

114108
const index = mountedIndexes.get(target);
115109
if (index != NULL) {
116-
resizes.push([index, contentRect[sizeKey]]);
110+
store.$update(ACTION_ITEM_RESIZE, [index, contentRect[sizeKey]]);
117111
}
118112
}
119-
120-
if (resizes.length) {
121-
store.$update(ACTION_ITEM_RESIZE, resizes);
122-
}
123113
});
124114

125115
let cleanupOnWindowResize: (() => void) | undefined;
@@ -221,7 +211,6 @@ export const createGridResizer = (
221211
}
222212

223213
if (resizedRows.size) {
224-
const heightResizes: ItemResize[] = [];
225214
resizedRows.forEach((rowIndex) => {
226215
let maxHeight = 0;
227216
maybeCachedColIndexes.forEach((colIndex) => {
@@ -231,13 +220,11 @@ export const createGridResizer = (
231220
}
232221
});
233222
if (maxHeight) {
234-
heightResizes.push([rowIndex, maxHeight]);
223+
vStore.$update(ACTION_ITEM_RESIZE, [rowIndex, maxHeight]);
235224
}
236225
});
237-
vStore.$update(ACTION_ITEM_RESIZE, heightResizes);
238226
}
239227
if (resizedCols.size) {
240-
const widthResizes: ItemResize[] = [];
241228
resizedCols.forEach((colIndex) => {
242229
let maxWidth = 0;
243230
maybeCachedRowIndexes.forEach((rowIndex) => {
@@ -247,10 +234,9 @@ export const createGridResizer = (
247234
}
248235
});
249236
if (maxWidth) {
250-
widthResizes.push([colIndex, maxWidth]);
237+
hStore.$update(ACTION_ITEM_RESIZE, [colIndex, maxWidth]);
251238
}
252239
});
253-
hStore.$update(ACTION_ITEM_RESIZE, widthResizes);
254240
}
255241
});
256242

@@ -269,20 +255,20 @@ export const createGridResizer = (
269255
};
270256
},
271257
$resizeCols(cols: ItemResize[]) {
272-
for (const [c] of cols) {
258+
for (const col of cols) {
273259
for (let r = 0; r < vStore.$getItemsLength(); r++) {
274-
sizeCache.delete(getKey(r, c));
260+
sizeCache.delete(getKey(r, col[0]));
275261
}
262+
hStore.$update(ACTION_ITEM_RESIZE, col);
276263
}
277-
hStore.$update(ACTION_ITEM_RESIZE, cols);
278264
},
279265
$resizeRows(rows: ItemResize[]) {
280-
for (const [r] of rows) {
266+
for (const row of rows) {
281267
for (let c = 0; c < hStore.$getItemsLength(); c++) {
282-
sizeCache.delete(getKey(r, c));
268+
sizeCache.delete(getKey(row[0], c));
283269
}
270+
vStore.$update(ACTION_ITEM_RESIZE, row);
284271
}
285-
vStore.$update(ACTION_ITEM_RESIZE, rows);
286272
},
287273
$dispose: resizeObserver._dispose,
288274
};

src/core/store.ts

Lines changed: 84 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
ItemResize,
1919
ItemsRange,
2020
} from "./types";
21-
import { abs, max, min, NULL } from "./utils";
21+
import { abs, max, microtask, min, NULL } from "./utils";
2222

2323
const MAX_INT_32 = 0x7fffffff;
2424

@@ -55,18 +55,21 @@ export const ACTION_MANUAL_SCROLL = 7;
5555
/** @internal */
5656
export const ACTION_BEFORE_MANUAL_SMOOTH_SCROLL = 8;
5757

58-
type Actions =
59-
| [type: typeof ACTION_SCROLL, offset: number]
60-
| [type: typeof ACTION_SCROLL_END, dummy?: void]
61-
| [type: typeof ACTION_ITEM_RESIZE, entries: ItemResize[]]
62-
| [type: typeof ACTION_VIEWPORT_RESIZE, size: number]
63-
| [
64-
type: typeof ACTION_ITEMS_LENGTH_CHANGE,
65-
arg: [length: number, isShift?: boolean | undefined],
66-
]
67-
| [type: typeof ACTION_START_OFFSET_CHANGE, offset: number]
68-
| [type: typeof ACTION_MANUAL_SCROLL, dummy?: void]
69-
| [type: typeof ACTION_BEFORE_MANUAL_SMOOTH_SCROLL, offset: number];
58+
type Actions = [
59+
...args:
60+
| [type: typeof ACTION_SCROLL, offset: number]
61+
| [type: typeof ACTION_SCROLL_END, dummy?: void]
62+
| [type: typeof ACTION_ITEM_RESIZE, entry: ItemResize]
63+
| [type: typeof ACTION_VIEWPORT_RESIZE, size: number]
64+
| [
65+
type: typeof ACTION_ITEMS_LENGTH_CHANGE,
66+
arg: [length: number, isShift?: boolean | undefined]
67+
]
68+
| [type: typeof ACTION_START_OFFSET_CHANGE, offset: number]
69+
| [type: typeof ACTION_MANUAL_SCROLL, dummy?: void]
70+
| [type: typeof ACTION_BEFORE_MANUAL_SMOOTH_SCROLL, offset: number],
71+
immediate?: boolean
72+
];
7073

7174
/** @internal */
7275
export const UPDATE_VIRTUAL_STATE = 0b0001;
@@ -133,7 +136,10 @@ export const createVirtualStore = (
133136
shouldAutoEstimateItemSize: boolean = false
134137
): VirtualStore => {
135138
let isSSR = !!ssrCount;
139+
let isRerenderScheduled = false;
140+
let shouldSync = false;
136141
let stateVersion: StateVersion = 1;
142+
let updatedBit = 0;
137143
let viewportSize = 0;
138144
let startSpacerSize = 0;
139145
let scrollOffset = 0;
@@ -246,10 +252,8 @@ export const createVirtualStore = (
246252
subscribers.delete(sub);
247253
};
248254
},
249-
$update: (type, payload): void => {
255+
$update: (type, payload, immediate = false) => {
250256
let shouldFlushPendingJump: boolean | undefined;
251-
let shouldSync: boolean | undefined;
252-
let mutated = 0;
253257

254258
switch (type) {
255259
case ACTION_SCROLL: {
@@ -291,7 +295,7 @@ export const createVirtualStore = (
291295
}
292296

293297
scrollOffset = payload;
294-
mutated = UPDATE_SCROLL_EVENT;
298+
updatedBit |= UPDATE_SCROLL_EVENT;
295299

296300
// Skip if offset is not changed
297301
// Scroll offset may exceed min or max especially in Safari's elastic scrolling.
@@ -300,69 +304,59 @@ export const createVirtualStore = (
300304
relativeOffset >= -viewportSize &&
301305
relativeOffset <= getTotalSize()
302306
) {
303-
mutated += UPDATE_VIRTUAL_STATE;
307+
updatedBit |= UPDATE_VIRTUAL_STATE;
304308

305309
// Update synchronously if scrolled a lot
306310
shouldSync = distance > viewportSize;
307311
}
308312
break;
309313
}
310314
case ACTION_SCROLL_END: {
311-
mutated = UPDATE_SCROLL_END_EVENT;
315+
updatedBit |= UPDATE_SCROLL_END_EVENT;
312316
if (_scrollDirection !== SCROLL_IDLE) {
313317
shouldFlushPendingJump = true;
314-
mutated += UPDATE_VIRTUAL_STATE;
318+
updatedBit |= UPDATE_VIRTUAL_STATE;
315319
}
316320
_scrollDirection = SCROLL_IDLE;
317321
_scrollMode = SCROLL_BY_NATIVE;
318322
_frozenRange = NULL;
319323
break;
320324
}
321325
case ACTION_ITEM_RESIZE: {
322-
const updated = payload.filter(
323-
([index, size]) => cache._sizes[index] !== size
324-
);
326+
const [index, size] = payload;
325327

326-
// Skip if all items are cached and not updated
327-
if (!updated.length) {
328+
// Skip if item is cached and not updated
329+
if (cache._sizes[index] === size) {
328330
break;
329331
}
330332

333+
const prevSize = getItemSize(index);
334+
331335
// Calculate jump by resize to minimize junks in appearance
332-
applyJump(
333-
updated.reduce((acc, [index, size]) => {
334-
if (
335-
// Keep distance from end during shifting
336-
_scrollMode === SCROLL_BY_SHIFT ||
337-
(_frozenRange
338-
? // https://github.com/inokawa/virtua/issues/380
339-
// https://github.com/inokawa/virtua/issues/590
340-
!isSSR && index < _frozenRange[0]
341-
: // Otherwise we should maintain visible position
342-
getItemOffset(index) +
343-
// https://github.com/inokawa/virtua/issues/385
344-
(_scrollDirection === SCROLL_IDLE &&
345-
_scrollMode === SCROLL_BY_NATIVE
346-
? getItemSize(index)
347-
: 0) <
348-
getRelativeScrollOffset())
349-
) {
350-
acc += size - getItemSize(index);
351-
}
352-
return acc;
353-
}, 0)
354-
);
355-
356-
// Update item sizes
357-
for (const [index, size] of updated) {
358-
const prevSize = getItemSize(index);
359-
const isInitialMeasurement = setItemSize(cache, index, size);
360-
361-
if (shouldAutoEstimateItemSize) {
362-
_totalMeasuredSize += isInitialMeasurement
363-
? size
364-
: size - prevSize;
365-
}
336+
if (
337+
// Keep distance from end during shifting
338+
_scrollMode === SCROLL_BY_SHIFT ||
339+
(_frozenRange
340+
? // https://github.com/inokawa/virtua/issues/380
341+
// https://github.com/inokawa/virtua/issues/590
342+
!isSSR && index < _frozenRange[0]
343+
: // Otherwise we should maintain visible position
344+
getItemOffset(index) +
345+
// https://github.com/inokawa/virtua/issues/385
346+
(_scrollDirection === SCROLL_IDLE &&
347+
_scrollMode === SCROLL_BY_NATIVE
348+
? prevSize
349+
: 0) <
350+
getRelativeScrollOffset())
351+
) {
352+
applyJump(size - prevSize);
353+
}
354+
355+
// Update item size
356+
const isInitialMeasurement = setItemSize(cache, index, size);
357+
358+
if (shouldAutoEstimateItemSize) {
359+
_totalMeasuredSize += isInitialMeasurement ? size : size - prevSize;
366360
}
367361

368362
// Estimate initial item size from measured sizes
@@ -381,7 +375,7 @@ export const createVirtualStore = (
381375
shouldAutoEstimateItemSize = false;
382376
}
383377

384-
mutated = UPDATE_VIRTUAL_STATE + UPDATE_SIZE_EVENT;
378+
updatedBit |= UPDATE_VIRTUAL_STATE | UPDATE_SIZE_EVENT;
385379

386380
// Synchronous update is necessary in current design to minimize visible glitch in concurrent rendering.
387381
// However this seems to be the main cause of the errors from ResizeObserver.
@@ -395,20 +389,20 @@ export const createVirtualStore = (
395389
case ACTION_VIEWPORT_RESIZE: {
396390
if (viewportSize !== payload) {
397391
viewportSize = payload;
398-
mutated = UPDATE_VIRTUAL_STATE + UPDATE_SIZE_EVENT;
392+
updatedBit |= UPDATE_VIRTUAL_STATE | UPDATE_SIZE_EVENT;
399393
}
400394
break;
401395
}
402396
case ACTION_ITEMS_LENGTH_CHANGE: {
403397
if (payload[1]) {
404398
applyJump(updateCacheLength(cache, payload[0], true));
405399
_scrollMode = SCROLL_BY_SHIFT;
406-
mutated = UPDATE_VIRTUAL_STATE;
400+
updatedBit |= UPDATE_VIRTUAL_STATE;
407401
} else {
408402
updateCacheLength(cache, payload[0]);
409403
// https://github.com/inokawa/virtua/issues/552
410404
// https://github.com/inokawa/virtua/issues/557
411-
mutated = UPDATE_VIRTUAL_STATE;
405+
updatedBit |= UPDATE_VIRTUAL_STATE;
412406
}
413407
break;
414408
}
@@ -422,28 +416,43 @@ export const createVirtualStore = (
422416
}
423417
case ACTION_BEFORE_MANUAL_SMOOTH_SCROLL: {
424418
_frozenRange = getRange(payload);
425-
mutated = UPDATE_VIRTUAL_STATE;
419+
updatedBit |= UPDATE_VIRTUAL_STATE;
426420
break;
427421
}
428422
}
429423

430-
if (mutated) {
431-
stateVersion = (stateVersion & MAX_INT_32) + 1;
432-
424+
if (updatedBit) {
433425
if (shouldFlushPendingJump && pendingJump) {
434426
jump += pendingJump;
435427
pendingJump = 0;
436428
}
437429

438-
subscribers.forEach(([target, cb]) => {
439-
// Early return to skip React's computation
440-
if (!(mutated & target)) {
441-
return;
430+
if (!isRerenderScheduled) {
431+
isRerenderScheduled = true;
432+
const flush = () => {
433+
const _updatedBit = updatedBit;
434+
const _shouldSync = shouldSync;
435+
updatedBit = 0;
436+
isRerenderScheduled = shouldSync = false;
437+
438+
stateVersion = (stateVersion & MAX_INT_32) + 1;
439+
440+
subscribers.forEach(([target, cb]) => {
441+
// Early return to skip React's computation
442+
if (!(_updatedBit & target)) {
443+
return;
444+
}
445+
// https://github.com/facebook/react/issues/25191
446+
// https://github.com/facebook/react/blob/a5fc797db14c6e05d4d5c4dbb22a0dd70d41f5d5/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1443-L1447
447+
cb(_shouldSync);
448+
});
449+
};
450+
if (immediate) {
451+
flush();
452+
} else {
453+
microtask(flush);
442454
}
443-
// https://github.com/facebook/react/issues/25191
444-
// https://github.com/facebook/react/blob/a5fc797db14c6e05d4d5c4dbb22a0dd70d41f5d5/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1443-L1447
445-
cb(shouldSync);
446-
});
455+
}
447456
}
448457
},
449458
};

0 commit comments

Comments
 (0)