Dashboards: Use IntersectionObserver to manage lazy loading of panels (#42834)

This commit is contained in:
kay delaney 2021-12-13 12:42:33 +00:00 committed by GitHub
parent 12619065a4
commit 597148f23b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 119 additions and 101 deletions

View File

@ -253,7 +253,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
panel={panel}
isEditing={true}
isViewing={false}
isInView={true}
lazy={false}
width={panelSize.width}
height={panelSize.height}
skipStateCleanUp={true}

View File

@ -2,6 +2,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import { Props, UnthemedDashboardPage } from './DashboardPage';
import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader';
import { Router } from 'react-router-dom';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { DashboardModel } from '../state';
@ -14,6 +15,13 @@ import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps
import { createTheme } from '@grafana/data';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader = ({ children }: Pick<LazyLoaderProps, 'children'>) => {
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
};
return { LazyLoader };
});
jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => {
class GeneralSettings extends React.Component<{}, {}> {
render() {

View File

@ -3,7 +3,7 @@ import { css } from '@emotion/css';
import { connect, ConnectedProps } from 'react-redux';
import { locationService } from '@grafana/runtime';
import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar, ScrollbarPosition, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { Branding } from 'app/core/components/Branding/Branding';
@ -75,7 +75,6 @@ export type Props = Themeable2 &
export interface State {
editPanel: PanelModel | null;
viewPanel: PanelModel | null;
scrollTop: number;
updateScrollTop?: number;
rememberScrollTop: number;
showLoadingState: boolean;
@ -92,7 +91,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
editPanel: null,
viewPanel: null,
showLoadingState: false,
scrollTop: 0,
rememberScrollTop: 0,
panelNotFound: false,
editPanelAccessDenied: false,
@ -252,7 +250,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return {
...state,
viewPanel: panel,
rememberScrollTop: state.scrollTop,
updateScrollTop: 0,
};
}
@ -273,10 +270,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return state;
}
setScrollTop = ({ scrollTop }: ScrollbarPosition): void => {
this.setState({ scrollTop, updateScrollTop: undefined });
};
onAddPanel = () => {
const { dashboard } = this.props;
@ -320,7 +313,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
render() {
const { dashboard, isInitSlow, initError, queryParams, theme } = this.props;
const { editPanel, viewPanel, scrollTop, updateScrollTop } = this.state;
const { editPanel, viewPanel, updateScrollTop } = this.state;
const kioskMode = getKioskMode(queryParams.kiosk);
const styles = getStyles(theme, kioskMode);
@ -332,8 +325,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return null;
}
// Only trigger render when the scroll has moved by 25
const approximateScrollTop = Math.round(scrollTop / 25) * 25;
const inspectPanel = this.getInspectPanel();
const containerClassNames = classnames(styles.dashboardContainer, {
'panel-in-fullscreen': viewPanel,
@ -361,7 +352,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
<div className={styles.dashboardScroll}>
<CustomScrollbar
autoHeightMin="100%"
setScrollTop={this.setScrollTop}
scrollTop={updateScrollTop}
hideHorizontalTrack={true}
updateAfterMountMs={500}
@ -374,12 +364,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
</section>
)}
<DashboardGrid
dashboard={dashboard}
viewPanel={viewPanel}
editPanel={editPanel}
scrollTop={approximateScrollTop}
/>
<DashboardGrid dashboard={dashboard} viewPanel={viewPanel} editPanel={editPanel} />
</div>
</CustomScrollbar>
</div>

View File

@ -115,7 +115,7 @@ export const SoloPanel = ({ dashboard, notFound, panel, panelId }: SoloPanelProp
panel={panel}
isEditing={false}
isViewing={false}
isInView={true}
lazy={false}
/>
);
}}

View File

@ -3,6 +3,13 @@ import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardGrid, Props } from './DashboardGrid';
import { DashboardModel } from '../state';
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader: React.FC = ({ children }) => {
return <>{children}</>;
};
return { LazyLoader };
});
interface ScenarioContext {
props: Props;
wrapper?: ShallowWrapper<Props, any, DashboardGrid>;
@ -59,7 +66,6 @@ function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioCo
props: {
editPanel: null,
viewPanel: null,
scrollTop: 0,
dashboard: getTestDashboard(),
},
setProps: (props: Partial<Props>) => {

View File

@ -21,7 +21,6 @@ export interface Props {
dashboard: DashboardModel;
editPanel: PanelModel | null;
viewPanel: PanelModel | null;
scrollTop: number;
}
export interface State {
@ -125,32 +124,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
this.updateGridPos(newItem, layout);
};
isInView(panel: PanelModel, gridWidth: number) {
if (panel.isViewing || panel.isEditing) {
return true;
}
const scrollTop = this.props.scrollTop;
const screenPos = this.getPanelScreenPos(panel, gridWidth);
// Show things that are almost in the view
const buffer = 100;
// The panel is above the viewport
if (scrollTop > screenPos.bottom + buffer) {
return false;
}
const scrollViewBottom = scrollTop + this.windowHeight;
// Panel is below view
if (screenPos.top > scrollViewBottom + buffer) {
return false;
}
return !this.props.dashboard.otherPanelInFullscreen(panel);
}
getPanelScreenPos(panel: PanelModel, gridWidth: number): { top: number; bottom: number } {
let top = 0;
@ -185,9 +158,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
for (const panel of this.props.dashboard.panels) {
const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing });
// Update is in view state
panel.isInView = this.isInView(panel, gridWidth);
panelElements.push(
<GrafanaGridItem
key={panel.key}
@ -226,7 +196,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
dashboard={this.props.dashboard}
isEditing={panel.isEditing}
isViewing={panel.isViewing}
isInView={panel.isInView}
width={width}
height={height}
/>
@ -235,13 +204,14 @@ export class DashboardGrid extends PureComponent<Props, State> {
render() {
const { dashboard } = this.props;
/**
* We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer
* properly working. For more information go here:
* https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#can-i-use-autosizer-within-a-flex-container
*/
return (
/**
* We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer
* properly working. For more information go here:
* https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#can-i-use-autosizer-within-a-flex-container
*/
<div style={{ flex: '1 1 auto' }}>
<div style={{ flex: '1 1 auto', display: this.props.editPanel ? 'none' : undefined }}>
<AutoSizer disableHeight>
{({ width }) => {
if (width === 0) {

View File

@ -7,6 +7,7 @@ import { StoreState } from 'app/types';
import { PanelPlugin } from '@grafana/data';
import { cleanUpPanelState, setPanelInstanceState } from '../../panel/state/reducers';
import { initPanelState } from '../../panel/state/actions';
import { LazyLoader } from './LazyLoader';
export interface OwnProps {
panel: PanelModel;
@ -14,14 +15,10 @@ export interface OwnProps {
dashboard: DashboardModel;
isEditing: boolean;
isViewing: boolean;
isInView: boolean;
width: number;
height: number;
skipStateCleanUp?: boolean;
}
export interface State {
isLazy: boolean;
lazy?: boolean;
}
const mapStateToProps = (state: StoreState, props: OwnProps) => {
@ -46,18 +43,15 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = OwnProps & ConnectedProps<typeof connector>;
export class DashboardPanelUnconnected extends PureComponent<Props, State> {
export class DashboardPanelUnconnected extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
lazy: true,
};
specialPanels: { [key: string]: Function } = {};
constructor(props: Props) {
super(props);
this.state = {
isLazy: !props.isInView,
};
}
componentDidMount() {
this.props.panel.isInView = !this.props.lazy;
if (!this.props.plugin) {
this.props.initPanelState(this.props.panel);
}
@ -70,21 +64,19 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
}
}
componentDidUpdate() {
if (this.state.isLazy && this.props.isInView) {
this.setState({ isLazy: false });
}
}
onInstanceStateChange = (value: any) => {
this.props.setPanelInstanceState({ key: this.props.stateKey, value });
};
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isViewing, isInView, isEditing, width, height } = this.props;
onVisibilityChange = (v: boolean) => {
this.props.panel.isInView = v;
};
if (plugin.angularPanelCtrl) {
return (
renderPanel(plugin: PanelPlugin) {
const { dashboard, panel, isViewing, isEditing, width, height, lazy } = this.props;
const renderPanelChrome = (isInView: boolean) =>
plugin.angularPanelCtrl ? (
<PanelChromeAngular
plugin={plugin}
panel={panel}
@ -95,38 +87,37 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
width={width}
height={height}
/>
) : (
<PanelChrome
plugin={plugin}
panel={panel}
dashboard={dashboard}
isViewing={isViewing}
isEditing={isEditing}
isInView={isInView}
width={width}
height={height}
onInstanceStateChange={this.onInstanceStateChange}
/>
);
}
return (
<PanelChrome
plugin={plugin}
panel={panel}
dashboard={dashboard}
isViewing={isViewing}
isEditing={isEditing}
isInView={isInView}
width={width}
height={height}
onInstanceStateChange={this.onInstanceStateChange}
/>
return lazy ? (
<LazyLoader width={width} height={height} onChange={this.onVisibilityChange}>
{({ isInView }) => renderPanelChrome(isInView)}
</LazyLoader>
) : (
renderPanelChrome(true)
);
}
render() {
const { plugin } = this.props;
const { isLazy } = this.state;
// If we have not loaded plugin exports yet, wait
if (!plugin) {
return null;
}
// If we are lazy state don't render anything
if (isLazy) {
return null;
}
return this.renderPanel(plugin);
}
}

View File

@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
import { useEffectOnce } from 'react-use';
export interface Props {
children: React.ReactNode | (({ isInView }: { isInView: boolean }) => React.ReactNode);
width?: number;
height?: number;
onLoad?: () => void;
onChange?: (isInView: boolean) => void;
}
export function LazyLoader({ children, width, height, onLoad, onChange }: Props) {
const id = useUniqueId();
const [loaded, setLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffectOnce(() => {
LazyLoader.addCallback(id, (entry) => {
if (!loaded && entry.isIntersecting) {
setLoaded(true);
onLoad?.();
}
setIsInView(entry.isIntersecting);
onChange?.(entry.isIntersecting);
});
if (wrapperRef.current) {
LazyLoader.observer.observe(wrapperRef.current);
}
return () => {
delete LazyLoader.callbacks[id];
if (Object.keys(LazyLoader.callbacks).length === 0) {
LazyLoader.observer.disconnect();
}
};
});
return (
<div id={id} ref={wrapperRef} style={{ width, height }}>
{loaded && (typeof children === 'function' ? children({ isInView }) : children)}
</div>
);
}
LazyLoader.callbacks = {} as Record<string, (e: IntersectionObserverEntry) => void>;
LazyLoader.addCallback = (id: string, c: (e: IntersectionObserverEntry) => void) => (LazyLoader.callbacks[id] = c);
LazyLoader.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
LazyLoader.callbacks[entry.target.id](entry);
}
},
{ rootMargin: '100px' }
);

View File

@ -4,6 +4,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
<div
style={
Object {
"display": undefined,
"flex": "1 1 auto",
}
}