azure-devops-ui
Version:
React components for building web UI in Azure DevOps
239 lines (238 loc) • 9.85 kB
JavaScript
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();
}
}