Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "feat: Enable complex dynamic scenarios with add and remove items, reduce remounting",
"packageName": "@fluentui-contrib/react-virtualizer",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,23 @@ export function useVirtualizer_unstable(
// We use this ref as a constant source to access the virtualizer's state imperatively
const actualIndexRef = React.useRef<number>(_virtualizerContext.contextIndex);

// The internal tracking REF for child array (updates often).
const childArray = React.useRef<React.ReactNode[]>(
new Array(virtualizerLength)
);

const flaggedIndex = React.useRef<number | null>(null);
const actualIndex = _virtualizerContext.contextIndex;

// Initialize the size array before first render.
const hasInitialized = React.useRef<boolean>(false);
const isFullyInitialized = hasInitialized.current && actualIndex >= 0;

// Just in case our ref gets out of date vs the context during a re-render
if (_virtualizerContext.contextIndex !== actualIndexRef.current) {
actualIndexRef.current = _virtualizerContext.contextIndex;
}

const setActualIndex = React.useCallback(
(index: number) => {
actualIndexRef.current = index;
Expand Down Expand Up @@ -74,12 +84,7 @@ export function useVirtualizer_unstable(
childProgressiveSizes.current;
}

// The internal tracking REF for child array (updates often).
const childArray = React.useRef<React.ReactNode[]>(
new Array(virtualizerLength)
);

const populateSizeArrays = () => {
const populateSizeArrays = React.useCallback(() => {
if (!getItemSize) {
// Static sizes, never mind!
return;
Expand Down Expand Up @@ -107,7 +112,7 @@ export function useVirtualizer_unstable(
childProgressiveSizes.current[index - 1] + childSizes.current[index];
}
}
};
}, [getItemSize, numItems, gap, virtualizerContext]);

const [isScrolling, setIsScrolling] = React.useState<boolean>(false);
const [setScrollTimer, clearScrollTimer] = useTimeout();
Expand Down Expand Up @@ -135,6 +140,7 @@ export function useVirtualizer_unstable(
}
clearScrollTimer();
setScrollTimer(() => {
updateChildArray(false);
setIsScrolling(false);
scrollCounter.current = 0;
}, INIT_SCROLL_FLAG_DELAY);
Expand All @@ -144,27 +150,69 @@ export function useVirtualizer_unstable(
initializeScrollingTimer();
}, [actualIndex, initializeScrollingTimer]);

const updateChildRows = React.useCallback(
// We track changes to prevent unnecessary renders
const prevIndex = React.useRef<number>(actualIndex);
const prevVirtualizerLength = React.useRef<number>(virtualizerLength);
const renderChildRows = React.useCallback(
(newIndex: number) => {
if (numItems === 0) {
/* Nothing to virtualize */
return;
return [];
}

/*
We reset the array every time to ensure children are re-rendered
This function should only be called when update is nessecary
*/
childArray.current = new Array(virtualizerLength);
const _actualIndex = Math.max(newIndex, 0);
const end = Math.min(_actualIndex + virtualizerLength, numItems);
for (let i = _actualIndex; i < end; i++) {
childArray.current[i - _actualIndex] = renderChild(i, isScrolling);
const arrayLength = Math.min(virtualizerLength, numItems - newIndex);
if (
prevIndex.current === newIndex &&
prevVirtualizerLength.current === virtualizerLength &&
arrayLength === childArray.current.length
) {
// We only want to re-render if the index or virtualizer length has changed
prevIndex.current = newIndex;
prevVirtualizerLength.current = virtualizerLength;
return childArray.current;
}

const newChildArray = new Array(arrayLength);
const indexChange = prevIndex.current - newIndex;
// We can copy some of the existing children
for (let i = 0; i < arrayLength; i++) {
const oldIndex = i - indexChange;
if (
childArray.current[oldIndex] !== undefined &&
oldIndex >= 0 &&
oldIndex < prevVirtualizerLength.current
) {
newChildArray[i] = childArray.current[oldIndex];
} else {
newChildArray[i] = renderChild(newIndex + i, isScrolling);
}
}

prevIndex.current = newIndex;
prevVirtualizerLength.current = virtualizerLength;
childArray.current = newChildArray;

return newChildArray;
},
[isScrolling, numItems, renderChild, virtualizerLength]
);

const updateChildArray = (_isScrolling: boolean) => {
// Render child changed, regenerate the child array
const arrayLength = Math.min(virtualizerLength, numItems - actualIndex);
const newChildArray = new Array(arrayLength);
for (let i = 0; i < arrayLength; i++) {
newChildArray[i] = renderChild(actualIndex + i, _isScrolling);
}
prevIndex.current = actualIndex;
prevVirtualizerLength.current = virtualizerLength;
childArray.current = newChildArray;
};

React.useEffect(() => {
updateChildArray(isScrolling);
}, [renderChild, isScrolling]);

const updateCurrentItemSizes = React.useCallback(
(newIndex: number) => {
if (!getItemSize) {
Expand Down Expand Up @@ -200,13 +248,11 @@ export function useVirtualizer_unstable(
const batchUpdateNewIndex = React.useCallback(
(index: number) => {
// Local updates
updateChildRows(index);
updateCurrentItemSizes(index);

// State setters
setActualIndex(index);
},
[setActualIndex, updateChildRows, updateCurrentItemSizes]
[setActualIndex, updateCurrentItemSizes]
);

const findIndexRecursive = React.useCallback(
Expand Down Expand Up @@ -453,16 +499,19 @@ export function useVirtualizer_unstable(

// Get exact relative 'scrollTop' via IO values
const measurementPos = calculateOverBuffer();
updateScrollPosition?.(measurementPos);

const maxIndex = Math.max(numItems - virtualizerLength, 0);
const maxIndex = Math.max(
numItems + bufferItems - virtualizerLength,
0
);

const startIndex =
getIndexFromScrollPosition(measurementPos) - bufferItems;

// Safety limits
const newStartIndex = Math.min(Math.max(startIndex, 0), maxIndex);
flushSync(() => {
// Callback to allow measure functions to check virtualizer length
if (
previousNumItems.current === numItems &&
newStartIndex + virtualizerLength >= numItems &&
Expand All @@ -473,7 +522,6 @@ export function useVirtualizer_unstable(
}
// We should ensure we update virtualizer calculations if the length changes
previousNumItems.current = virtualizerLength;
updateScrollPosition?.(measurementPos);
if (actualIndex !== newStartIndex) {
batchUpdateNewIndex(newStartIndex);
}
Expand Down Expand Up @@ -544,8 +592,6 @@ export function useVirtualizer_unstable(
[setObserverList]
);

// Initialize the size array before first render.
const hasInitialized = React.useRef<boolean>(false);
const initializeSizeArray = () => {
if (hasInitialized.current === false) {
hasInitialized.current = true;
Expand Down Expand Up @@ -574,28 +620,12 @@ export function useVirtualizer_unstable(
if (actualIndex < 0) {
batchUpdateNewIndex(0);
}
initializeSizeArray();
}, []);

/*
* forceUpdate:
* We only want to trigger this when child render or scroll loading changes,
* it will force re-render all children elements
*/
const forceUpdate = React.useReducer(() => ({}), {})[1];
// If the user passes in an updated renderChild function - update current children
React.useEffect(() => {
if (actualIndex >= 0) {
updateChildRows(actualIndex);
forceUpdate();
}
}, [renderChild, isScrolling]);

React.useEffect(() => {
// Ensure we repopulate if getItemSize callback changes
populateSizeArrays();

// We only run this effect on getItemSize change (recalc dynamic sizes)
}, [getItemSize, gap]);
}, [populateSizeArrays]);

// Effect to check flag index on updates
React.useEffect(() => {
Expand All @@ -611,36 +641,14 @@ export function useVirtualizer_unstable(
}
}, [actualIndex, onRenderedFlaggedIndex, virtualizerLength]);

// Ensure we have run through and updated the whole size list array at least once.
initializeSizeArray();

if (
getItemSize &&
(numItems !== childSizes.current.length ||
numItems !== childProgressiveSizes.current.length)
) {
// Child length mismatch, repopulate size arrays.
populateSizeArrays();
}

// Ensure we recalc if virtualizer length changes
const maxCompare = Math.min(virtualizerLength, numItems);
if (
childArray.current.length !== maxCompare &&
actualIndex + childArray.current.length < numItems
) {
updateChildRows(actualIndex);
}

const isFullyInitialized = hasInitialized.current && actualIndex >= 0;
return {
components: {
before: 'div',
after: 'div',
beforeContainer: 'div',
afterContainer: 'div',
},
virtualizedChildren: childArray.current,
virtualizedChildren: renderChildRows(actualIndex),
before: slot.always(props.before, {
defaultProps: {
ref: setBeforeRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function useVirtualizerScrollViewDynamic_unstable(
bufferItems: _bufferItems,
bufferSize: _bufferSize,
enableScrollAnchor,
gap = 0,
} = props;

const sizeTrackingArray = React.useRef<number[]>(
Expand Down Expand Up @@ -70,6 +71,7 @@ export function useVirtualizerScrollViewDynamic_unstable(
numItems: props.numItems,
bufferItems: _bufferItems,
bufferSize: _bufferSize,
gap,
});

const _imperativeVirtualizerRef = useMergedRefs(
Expand Down Expand Up @@ -145,7 +147,7 @@ export function useVirtualizerScrollViewDynamic_unstable(
_imperativeVirtualizerRef.current.setFlaggedIndex(index);
scrollToItemDynamic({
index,
itemSizes: sizeTrackingArray,
getItemSize: props.getItemSize ?? getChildSizeAuto,
totalSize,
scrollViewRef: scrollViewRef as React.RefObject<HTMLDivElement>,
axis,
Expand Down
1 change: 1 addition & 0 deletions packages/react-virtualizer/src/hooks/hooks.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type VirtualizerMeasureDynamicProps = {
numItems: number;
getItemSize: (index: number) => number;
direction?: 'vertical' | 'horizontal';
gap?: number;

/**
* Override recommended number of buffer items
Expand Down
Loading