diff --git a/examples/react/dynamic/src/main.tsx b/examples/react/dynamic/src/main.tsx index 1364de4be..a3a25d448 100644 --- a/examples/react/dynamic/src/main.tsx +++ b/examples/react/dynamic/src/main.tsx @@ -9,27 +9,40 @@ import './index.css' const randomNumber = (min: number, max: number) => faker.number.int({ min, max }) -const sentences = new Array(10000) - .fill(true) - .map(() => faker.lorem.sentence(randomNumber(20, 70))) +const getSentences = () => + new Array(randomNumber(1, 1000)) + .fill(true) + .map(() => faker.lorem.sentence(randomNumber(20, 70))) function RowVirtualizerDynamic() { + const [sentences, setSentences] = React.useState(getSentences()) const parentRef = React.useRef(null) + const innerRef = React.useRef(null) const [enabled, setEnabled] = React.useState(true) const count = sentences.length + + // This only rerenders when indexes change const virtualizer = useVirtualizer({ count, getScrollElement: () => parentRef.current, - estimateSize: () => 45, + getInnerElement: () => innerRef.current, + estimateSize: () => 100, enabled, }) - const items = virtualizer.getVirtualItems() + console.count('render') return (
+ + +
-
- {items.map((virtualRow) => ( -
-
-
Row {virtualRow.index}
-
{sentences[virtualRow.index]}
-
+ {virtualizer.indexes.map((index) => ( +
+
+
Row {index}
+
{sentences[index]}
- ))} -
+
+ ))}
diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index d835e7c4b..c8fdfdc0a 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -19,12 +19,17 @@ const useIsomorphicLayoutEffect = function useVirtualizerBase< TScrollElement extends Element | Window, TItemElement extends Element, + TTotalSizeElement extends Element, >( - options: VirtualizerOptions, -): Virtualizer { + options: VirtualizerOptions, +): Virtualizer { const rerender = React.useReducer(() => ({}), {})[1] - const resolvedOptions: VirtualizerOptions = { + const resolvedOptions: VirtualizerOptions< + TScrollElement, + TItemElement, + TTotalSizeElement + > = { ...options, onChange: (instance, sync) => { if (sync) { @@ -37,7 +42,10 @@ function useVirtualizerBase< } const [instance] = React.useState( - () => new Virtualizer(resolvedOptions), + () => + new Virtualizer( + resolvedOptions, + ), ) instance.setOptions(resolvedOptions) @@ -56,13 +64,14 @@ function useVirtualizerBase< export function useVirtualizer< TScrollElement extends Element, TItemElement extends Element, + TTotalSizeElement extends Element, >( options: PartialKeys< - VirtualizerOptions, + VirtualizerOptions, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' >, -): Virtualizer { - return useVirtualizerBase({ +): Virtualizer { + return useVirtualizerBase({ observeElementRect: observeElementRect, observeElementOffset: observeElementOffset, scrollToFn: elementScroll, @@ -72,14 +81,14 @@ export function useVirtualizer< export function useWindowVirtualizer( options: PartialKeys< - VirtualizerOptions, + VirtualizerOptions, | 'getScrollElement' | 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' >, -): Virtualizer { - return useVirtualizerBase({ +): Virtualizer { + return useVirtualizerBase({ getScrollElement: () => (typeof document !== 'undefined' ? window : null), observeElementRect: observeWindowRect, observeElementOffset: observeWindowOffset, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 20c2e4235..94c2a29dc 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -60,7 +60,7 @@ export const defaultRangeExtractor = (range: Range) => { } export const observeElementRect = ( - instance: Virtualizer, + instance: Virtualizer, cb: (rect: Rect) => void, ) => { const element = instance.scrollElement @@ -107,7 +107,7 @@ const addEventListenerOptions = { } export const observeWindowRect = ( - instance: Virtualizer, + instance: Virtualizer, cb: (rect: Rect) => void, ) => { const element = instance.scrollElement @@ -133,7 +133,7 @@ const supportsScrollend = type ObserveOffsetCallBack = (offset: number, isScrolling: boolean) => void export const observeElementOffset = ( - instance: Virtualizer, + instance: Virtualizer, cb: ObserveOffsetCallBack, ) => { const element = instance.scrollElement @@ -179,7 +179,7 @@ export const observeElementOffset = ( } export const observeWindowOffset = ( - instance: Virtualizer, + instance: Virtualizer, cb: ObserveOffsetCallBack, ) => { const element = instance.scrollElement @@ -224,7 +224,7 @@ export const observeWindowOffset = ( export const measureElement = ( element: TItemElement, entry: ResizeObserverEntry | undefined, - instance: Virtualizer, + instance: Virtualizer, ) => { if (entry?.borderBoxSize) { const box = entry.borderBoxSize[0] @@ -248,7 +248,7 @@ export const windowScroll = ( adjustments = 0, behavior, }: { adjustments?: number; behavior?: ScrollBehavior }, - instance: Virtualizer, + instance: Virtualizer, ) => { const toOffset = offset + adjustments @@ -264,7 +264,7 @@ export const elementScroll = ( adjustments = 0, behavior, }: { adjustments?: number; behavior?: ScrollBehavior }, - instance: Virtualizer, + instance: Virtualizer, ) => { const toOffset = offset + adjustments @@ -274,40 +274,77 @@ export const elementScroll = ( }) } +export const defaultApplyTotalSize = ( + element: Element | undefined | null, + instance: Virtualizer, +) => { + if (!(element instanceof HTMLElement)) { + throw Error( + process.env.NODE_ENV === 'development' + ? 'Element is not an HTMLElement' + : '', + ) + } + + element.style.height = `${instance.getTotalSize()}px` +} + +export const defaultApplyElementStyles = ( + data: { + node: Element | null | undefined + index: number + }, + instance: Virtualizer, +) => { + if (!(data.node instanceof HTMLElement)) { + throw Error( + process.env.NODE_ENV === 'development' + ? 'Element is not an HTMLElement' + : '', + ) + } + + const offset = instance.measurementsCache[data.index]?.start ?? 0 + + data.node.style.transform = `translateY(${offset}px)` +} + export interface VirtualizerOptions< TScrollElement extends Element | Window, TItemElement extends Element, + TTotalSizeElement extends Element, > { // Required from the user count: number getScrollElement: () => TScrollElement | null + getInnerElement: () => TItemElement | null estimateSize: (index: number) => number // Required from the framework adapter (but can be overridden) scrollToFn: ( offset: number, options: { adjustments?: number; behavior?: ScrollBehavior }, - instance: Virtualizer, + instance: Virtualizer, ) => void observeElementRect: ( - instance: Virtualizer, + instance: Virtualizer, cb: (rect: Rect) => void, ) => void | (() => void) observeElementOffset: ( - instance: Virtualizer, + instance: Virtualizer, cb: ObserveOffsetCallBack, ) => void | (() => void) // Optional debug?: boolean initialRect?: Rect onChange?: ( - instance: Virtualizer, + instance: Virtualizer, sync: boolean, ) => void measureElement?: ( element: TItemElement, entry: ResizeObserverEntry | undefined, - instance: Virtualizer, + instance: Virtualizer, ) => number overscan?: number horizontal?: boolean @@ -327,15 +364,92 @@ export interface VirtualizerOptions< useScrollendEvent?: boolean enabled?: boolean isRtl?: boolean + applyTotalSize?: ( + element: Element | undefined | null, + instance: Virtualizer, + ) => void + applyElementStyles?: ( + data: { + node: Element | null | undefined + index: number + }, + instance: Virtualizer, + ) => void +} + +export type AnyEmitterEvent = { + type: string +} + +class Emitter { + events = new Map void>>() + + on( + type: T, + cb: (event: Extract) => void, + ) { + let listeners = this.events.get(type) + if (!listeners) { + listeners = new Set() + this.events.set(type, listeners) + } + listeners.add(cb as any) + return () => listeners.delete(cb as any) + } + + emit(event: T) { + const listeners = this.events.get(event.type) + if (listeners) { + listeners.forEach((cb) => cb(event)) + } + } + + destroy() { + this.events.clear() + } +} + +export type VirtualizerEvent = + | VirtualizerEventOnScrollRect + | VirtualizerEventOnScrollOffset + | VirtualizerEventOnElementEnter + | VirtualizerEventOnElementResize + +export type VirtualizerEventOnScrollRect = { + type: 'scrollRect' + rect: Rect +} + +export type VirtualizerEventOnScrollOffset = { + type: 'scrollOffset' + offset: number + isScrolling: boolean +} + +export type VirtualizerEventOnElementEnter = { + type: 'elementEnter' + node: Element | null | undefined + index: number +} + +export type VirtualizerEventOnElementResize = { + type: 'elementResize' + node: Element | null | undefined + index: number + size: number } export class Virtualizer< TScrollElement extends Element | Window, TItemElement extends Element, -> { + TTotalSizeElement extends Element, +> extends Emitter { private unsubs: Array void)> = [] - options!: Required> + options!: Required< + VirtualizerOptions + > scrollElement: TScrollElement | null = null + innerElement: TItemElement | null = null targetWindow: (Window & typeof globalThis) | null = null isScrolling = false private scrollToIndexTimeoutId: number | null = null @@ -351,7 +465,7 @@ export class Virtualizer< | (( item: VirtualItem, delta: number, - instance: Virtualizer, + instance: Virtualizer, ) => boolean) elementsCache = new Map() private observer = (() => { @@ -368,7 +482,7 @@ export class Virtualizer< return (_ro = new this.targetWindow.ResizeObserver((entries) => { entries.forEach((entry) => { - this._measureElement(entry.target as TItemElement, entry) + this.measureElement(entry.target as TItemElement, entry) }) })) } @@ -385,11 +499,18 @@ export class Virtualizer< })() range: { startIndex: number; endIndex: number } | null = null - constructor(opts: VirtualizerOptions) { + constructor( + opts: VirtualizerOptions, + ) { + super() this.setOptions(opts) } - setOptions = (opts: VirtualizerOptions) => { + indexes: Array = [] + + setOptions = ( + opts: VirtualizerOptions, + ) => { Object.entries(opts).forEach(([key, value]) => { if (typeof value === 'undefined') delete (opts as any)[key] }) @@ -417,6 +538,8 @@ export class Virtualizer< enabled: true, isRtl: false, useScrollendEvent: true, + applyTotalSize: defaultApplyTotalSize, + applyElementStyles: defaultApplyElementStyles, ...opts, } } @@ -425,31 +548,8 @@ export class Virtualizer< this.options.onChange?.(this, sync) } - private maybeNotify = memo( - () => { - this.calculateRange() - - return [ - this.isScrolling, - this.range ? this.range.startIndex : null, - this.range ? this.range.endIndex : null, - ] - }, - (isScrolling) => { - this.notify(isScrolling) - }, - { - key: process.env.NODE_ENV !== 'production' && 'maybeNotify', - debug: () => this.options.debug, - initialDeps: [ - this.isScrolling, - this.range ? this.range.startIndex : null, - this.range ? this.range.endIndex : null, - ] as [boolean, number | null, number | null], - }, - ) - - private cleanup = () => { + destroy = () => { + super.destroy() this.unsubs.filter(Boolean).forEach((d) => d!()) this.unsubs = [] this.observer.disconnect() @@ -458,21 +558,43 @@ export class Virtualizer< } _didMount = () => { + this.updateScrollIndexes() + return () => { - this.cleanup() + this.destroy() } } _willUpdate = () => { + this.updateScrollElement() + this.updateInnerElement() + this.measure() + } + + private updateInnerElement = () => { + const innerElement = this.options.enabled + ? this.options.getInnerElement() + : null + + if (this.innerElement !== innerElement) { + this.innerElement = innerElement + + const cb = () => this.options.applyTotalSize(this.innerElement, this) + cb() + // this.on('onTotalResize', cb) + } + } + + private updateScrollElement = () => { const scrollElement = this.options.enabled ? this.options.getScrollElement() : null if (this.scrollElement !== scrollElement) { - this.cleanup() + this.destroy() if (!scrollElement) { - this.maybeNotify() + this.notify(true) return } @@ -496,27 +618,76 @@ export class Virtualizer< this.unsubs.push( this.options.observeElementRect(this, (rect) => { this.scrollRect = rect - this.maybeNotify() + this.emit({ + type: 'scrollRect', + rect, + }) }), ) this.unsubs.push( this.options.observeElementOffset(this, (offset, isScrolling) => { + this.emit({ + type: 'scrollOffset', + offset, + isScrolling, + }) + }), + ) + + this.unsubs.push( + this.on('scrollOffset', (event) => { this.scrollAdjustments = 0 - this.scrollDirection = isScrolling - ? this.getScrollOffset() < offset + this.scrollDirection = event.isScrolling + ? this.getScrollOffset() < event.offset ? 'forward' : 'backward' : null - this.scrollOffset = offset - this.isScrolling = isScrolling + this.scrollOffset = event.offset + this.isScrolling = event.isScrolling - this.maybeNotify() + this.updateScrollIndexes() + }), + ) + + this.unsubs.push( + this.on('elementEnter', (event) => { + this.options.applyElementStyles(event, this) + }), + ) + + this.unsubs.push( + this.on('elementResize', (event) => { + this.updateScrollIndexes() + this.applyAllElementStyles() }), ) } } + private applyAllElementStyles = () => { + this.indexes.forEach((index) => { + const element = this.elementsCache.get(index) + if (element) { + this.options.applyElementStyles( + { + node: element, + index, + }, + this, + ) + } + }) + } + + private updateScrollIndexes = () => { + const prevIndexes = this.indexes + this.indexes = this.getVirtualIndexes() + if (prevIndexes !== this.indexes) { + this.notify(false) + } + } + private getSize = () => { if (!this.options.enabled) { this.scrollRect = null @@ -743,15 +914,32 @@ export class Virtualizer< return parseInt(indexStr, 10) } - private _measureElement = ( + handleElement = (node: TItemElement | null | undefined) => { + if (!node) { + this.elementsCache.forEach((cached, key) => { + if (!cached.isConnected) { + this.observer.unobserve(cached) + this.elementsCache.delete(key) + } + }) + + return + } + + this.measureElement(node, undefined) + this.emit({ + type: 'elementEnter', + node, + index: this.indexFromElement(node), + }) + } + + private measureElement = ( node: TItemElement, entry: ResizeObserverEntry | undefined, ) => { const index = this.indexFromElement(node) - const item = this.measurementsCache[index] - if (!item) { - return - } + const item = this.measurementsCache[index]! const key = item.key const prevNode = this.elementsCache.get(key) @@ -764,75 +952,41 @@ export class Virtualizer< } if (node.isConnected) { - this.resizeItem(index, this.options.measureElement(node, entry, this)) - } - } - - resizeItem = (index: number, size: number) => { - const item = this.measurementsCache[index] - if (!item) { - return - } - const itemSize = this.itemSizeCache.get(item.key) ?? item.size - const delta = size - itemSize + const prevSize = this.itemSizeCache.get(item.key) ?? item.size + const size = this.options.measureElement(node, entry, this) + const delta = size - prevSize + + if (delta !== 0) { + if ( + this.shouldAdjustScrollPositionOnItemSizeChange !== undefined + ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) + : item.start < this.getScrollOffset() + this.scrollAdjustments + ) { + if (process.env.NODE_ENV !== 'production' && this.options.debug) { + console.info('correction', delta) + } - if (delta !== 0) { - if ( - this.shouldAdjustScrollPositionOnItemSizeChange !== undefined - ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) - : item.start < this.getScrollOffset() + this.scrollAdjustments - ) { - if (process.env.NODE_ENV !== 'production' && this.options.debug) { - console.info('correction', delta) + this._scrollToOffset(this.getScrollOffset(), { + adjustments: (this.scrollAdjustments += delta), + behavior: undefined, + }) } - this._scrollToOffset(this.getScrollOffset(), { - adjustments: (this.scrollAdjustments += delta), - behavior: undefined, - }) - } - - this.pendingMeasuredCacheIndexes.push(item.index) - this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size)) + this.pendingMeasuredCacheIndexes.push(item.index) + this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size)) - this.notify(false) - } - } + console.log('elementResize', item.key, size) - measureElement = (node: TItemElement | null | undefined) => { - if (!node) { - this.elementsCache.forEach((cached, key) => { - if (!cached.isConnected) { - this.observer.unobserve(cached) - this.elementsCache.delete(key) - } - }) - return + this.emit({ + type: 'elementResize', + node: this.elementsCache.get(item.key), + index: item.index, + size: size, + }) + } } - - this._measureElement(node, undefined) } - getVirtualItems = memo( - () => [this.getVirtualIndexes(), this.getMeasurements()], - (indexes, measurements) => { - const virtualItems: Array = [] - - for (let k = 0, len = indexes.length; k < len; k++) { - const i = indexes[k]! - const measurement = measurements[i]! - - virtualItems.push(measurement) - } - - return virtualItems - }, - { - key: process.env.NODE_ENV !== 'production' && 'getVirtualItems', - debug: () => this.options.debug, - }, - ) - getVirtualItemForOffset = (offset: number) => { const measurements = this.getMeasurements() if (measurements.length === 0) { @@ -1047,8 +1201,15 @@ export class Virtualizer< } measure = () => { - this.itemSizeCache = new Map() - this.notify(false) + // this.itemSizeCache = new Map() + this.indexes.forEach((index) => { + const element = this.elementsCache.get(index) + if (element) { + this.measureElement(element, undefined) + } + }) + // this.updateScrollIndexes() + // this.applyAllElementStyles() } }