UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

239 lines (238 loc) 9.85 kB
import "../../CommonImports"; import "../../Core/core.css"; import * as React from "react"; import { createMergedRef } from '../../Util'; // We need to monitor fine grained changes, especially when the list // has horizontal scroll. You dont get 100% visible ever. const defaultThreshold = []; for (let index = 0; index <= 100; index++) { defaultThreshold.push(index / 100); } // Optimized threshold - sufficient for most visibility detection while reducing callback frequency by ~93% const optimizedThreshold = [0, .1, .2, .3, .4, .5, .6, .7, .8, .9, 1]; class IntersectionContextImpl { constructor() { this.callbacks = []; this.callbacksSet = new Set(); this.pending = []; this.pendingSet = new Set(); // Throttling state for scroll events this.scrollThrottleId = null; this.lastScrollTime = 0; this.rootMargin = 0; this.root = document.body; this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS = window.__VSS_INTERSECTION_PERFORMANCE_IMPROVEMENT_ENABLED === true; this.onIntersect = (entries) => { if (this.observer) { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { // Note: We do NOT skip empty entries - they are essential for scroll-based virtualization. // Empty entries are intentionally passed during scroll events to trigger row materialization. // Performance gains come from reduced threshold array and scroll throttling, not from skipping callbacks. for (const callback of this.callbacksSet) { callback(entries, this.observer); } } else { for (const callback of this.callbacks) { callback(entries, this.observer); } } } }; // Throttled scroll notification for optimized path this.onScrollThrottled = () => { if (!this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.onIntersect([]); return; } const now = performance.now(); // In optimized mode, we still want to allow intersection events but throttle the empty scroll events // The IntersectionObserver will naturally fire when elements come into/out of view // We only throttle the artificial empty scroll events, but still fire them occasionally to ensure state sync if (now - this.lastScrollTime >= IntersectionContextImpl.SCROLL_THROTTLE_MS) { this.lastScrollTime = now; // Fire an empty intersection event to trigger list processing // This ensures scroll-based virtualization still works even in optimized mode this.onIntersect([]); if (this.scrollThrottleId !== null) { cancelAnimationFrame(this.scrollThrottleId); this.scrollThrottleId = null; } } else if (this.scrollThrottleId === null) { this.scrollThrottleId = requestAnimationFrame(() => { this.scrollThrottleId = null; this.lastScrollTime = performance.now(); // Fire the throttled empty intersection event this.onIntersect([]); }); } }; } get performanceOptimizationsEnabled() { return this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS; } connect(root, rootMargin = 0, threshold) { // Use the appropriate threshold based on the flag const finalThreshold = threshold || (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS ? optimizedThreshold : defaultThreshold); this.observer = new IntersectionObserver(this.onIntersect, { root, rootMargin: rootMargin + "px", threshold: finalThreshold }); this.rootMargin = rootMargin; this.root = root; // Fix TypeScript error by handling each collection type separately if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { for (const element of this.pendingSet) { this.observer.observe(element); } } else { for (const element of this.pending) { this.observer.observe(element); } } } disconnect() { if (this.observer) { this.observer.disconnect(); } if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS && this.scrollThrottleId !== null) { cancelAnimationFrame(this.scrollThrottleId); this.scrollThrottleId = null; } } observe(element) { if (this.observer) { this.observer.observe(element); } else { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.pendingSet.add(element); } else { this.pending.push(element); } } } register(callback) { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.callbacksSet.add(callback); } else { this.callbacks.push(callback); } } unobserve(element) { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.pendingSet.delete(element); } else { const elementIndex = this.pending.indexOf(element); if (elementIndex >= 0) { this.pending.splice(elementIndex, 1); } } if (this.observer) { this.observer.unobserve(element); } } unregister(callback) { if (this.ENABLE_INTERSECTION_PERF_OPTIMIZATIONS) { this.callbacksSet.delete(callback); } else { const callbackIndex = this.callbacks.indexOf(callback); if (callbackIndex >= 0) { this.callbacks.splice(callbackIndex, 1); } } } } IntersectionContextImpl.SCROLL_THROTTLE_MS = 16; // ~60fps export const IntersectionContext = React.createContext(new IntersectionContextImpl()); /** * The Intersection is used to observe the changes of visibility in the children * of the rootElement. It also will notify the caller when the rootElement is * scrolled. It will pass an empty array of entries in the scorlling case. */ export class Intersection extends React.Component { constructor() { super(...arguments); this.mergedRef = createMergedRef(); this.rootElement = React.createRef(); this.state = new IntersectionContextImpl(); this.onScroll = (event) => { if (this.state.performanceOptimizationsEnabled) { this.state.onScrollThrottled(); } else { this.state.onIntersect([]); } }; } // Render the provider around a SINGLE child. This is the element that is scrollable. render() { const child = React.Children.only(this.props.children); let onScroll; if (child.props.onScroll) { onScroll = (event) => { if (child.props.onScroll) { child.props.onScroll(event); } this.onScroll(event); }; } else { onScroll = this.onScroll; } return (React.createElement(IntersectionContext.Provider, { value: this.state }, React.cloneElement(child, Object.assign(Object.assign({}, child.props), { ref: this.mergedRef(this.rootElement, child.ref), onScroll }), child.props.children))); } componentDidMount() { const { observationElement, rootElement } = this.props; let connectElement = null; if (rootElement) { if (typeof rootElement === "string") { connectElement = document.querySelector(rootElement); } else if (typeof rootElement === "function") { connectElement = rootElement(); } else { connectElement = rootElement; } if (connectElement) { connectElement.addEventListener("scroll", this.onScroll); this.externalElement = connectElement; } } else if (this.rootElement) { connectElement = this.rootElement.current; } if (connectElement) { this.state.connect(connectElement, this.props.rootMargin, this.props.threshold); // Allow the creator of the intersection to observe intersection events. if (this.props.onIntersect) { this.state.register(this.props.onIntersect); } if (observationElement) { let observeElement; if (typeof observationElement === "string") { observeElement = document.querySelector(observationElement); } else if (typeof observationElement === "function") { observeElement = observationElement(); } else { observeElement = observationElement; } if (observeElement) { this.state.observe(observeElement); } } } } componentWillUnmount() { if (this.externalElement) { this.externalElement.removeEventListener("scroll", this.onScroll); } this.state.disconnect(); } }