diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index f314748c14661..229a88d6a6f9b 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -31,6 +31,7 @@ export enum FeatureFlag {
DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL',
DASHBOARD_NATIVE_FILTERS = 'DASHBOARD_NATIVE_FILTERS',
DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET',
+ DASHBOARD_VIRTUALIZATION = 'DASHBOARD_VIRTUALIZATION',
DASHBOARD_RBAC = 'DASHBOARD_RBAC',
DATAPANEL_CLOSED_BY_DEFAULT = 'DATAPANEL_CLOSED_BY_DEFAULT',
DISABLE_DATASET_SOURCE_EDIT = 'DISABLE_DATASET_SOURCE_EDIT',
diff --git a/superset-frontend/src/components/Chart/Chart.jsx b/superset-frontend/src/components/Chart/Chart.jsx
index 893665ab15c7a..38b092bc877e2 100644
--- a/superset-frontend/src/components/Chart/Chart.jsx
+++ b/superset-frontend/src/components/Chart/Chart.jsx
@@ -28,6 +28,7 @@ import ErrorBoundary from 'src/components/ErrorBoundary';
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
import { URL_PARAMS } from 'src/constants';
import { getUrlParam } from 'src/utils/urlUtils';
+import { isCurrentUserBot } from 'src/utils/isBot';
import { ResourceStatus } from 'src/hooks/apiResources/apiResources';
import ChartRenderer from './ChartRenderer';
import { ChartErrorMessage } from './ChartErrorMessage';
@@ -74,6 +75,7 @@ const propTypes = {
ownState: PropTypes.object,
postTransformProps: PropTypes.func,
datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']),
+ isInView: PropTypes.bool,
};
const BLANK = {};
@@ -92,6 +94,7 @@ const defaultProps = {
chartStackTrace: null,
isDeactivatedViz: false,
force: false,
+ isInView: true,
};
const Styles = styled.div`
@@ -307,11 +310,17 @@ class Chart extends React.PureComponent {
width={width}
>
-
+ {this.props.isInView ||
+ !isFeatureEnabled(FeatureFlag.DASHBOARD_VIRTUALIZATION) ||
+ isCurrentUserBot() ? (
+
+ ) : (
+
+ )}
{isLoading && !isDeactivatedViz && }
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
index 314dd6d1cb0ea..9e3e3e94b61d4 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Chart.jsx
@@ -92,6 +92,7 @@ const propTypes = {
filterState: PropTypes.object,
postTransformProps: PropTypes.func,
datasetsStatus: PropTypes.oneOf(['loading', 'error', 'complete']),
+ isInView: PropTypes.bool,
};
const defaultProps = {
@@ -382,6 +383,7 @@ class Chart extends React.Component {
filterboxMigrationState,
postTransformProps,
datasetsStatus,
+ isInView,
} = this.props;
const { width } = this.state;
@@ -511,6 +513,7 @@ class Chart extends React.Component {
filterboxMigrationState={filterboxMigrationState}
postTransformProps={postTransformProps}
datasetsStatus={datasetsStatus}
+ isInView={isInView}
/>
diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx
index 62ad0584f0b77..4470d616fc591 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.test.tsx
@@ -137,7 +137,7 @@ describe('ChartHolder', () => {
rerender(
-
+
,
);
@@ -414,6 +414,7 @@ describe('ChartHolder', () => {
deleteComponent={deleteComponent}
fullSizeChartId={null}
editMode
+ isInView
/>
,
);
diff --git a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
index c3cffe338c8e5..103fbf273efa2 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/ChartHolder.tsx
@@ -67,6 +67,8 @@ interface ChartHolderProps {
handleComponentDrop: (...args: unknown[]) => unknown;
setFullSizeChartId: (chartId: number | null) => void;
postAddSliceFromDashboard?: () => void;
+
+ isInView: boolean;
}
const ChartHolder: React.FC = ({
@@ -91,6 +93,7 @@ const ChartHolder: React.FC = ({
handleComponentDrop,
setFullSizeChartId,
postAddSliceFromDashboard,
+ isInView,
}) => {
const { chartId } = component.meta;
const isFullSize = fullSizeChartId === chartId;
@@ -314,6 +317,7 @@ const ChartHolder: React.FC = ({
setControlValue={handleExtraControl}
extraControls={extraControls}
postTransformProps={handlePostTransformProps}
+ isInView={isInView}
/>
{editMode && (
diff --git a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx
index b0037bf12322a..26980701c7a4f 100644
--- a/superset-frontend/src/dashboard/components/gridComponents/Row.jsx
+++ b/superset-frontend/src/dashboard/components/gridComponents/Row.jsx
@@ -19,6 +19,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
+import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import DragDroppable from 'src/dashboard/components/dnd/DragDroppable';
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
@@ -32,6 +33,7 @@ import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
import { componentShape } from 'src/dashboard/util/propShapes';
import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
+import { isCurrentUserBot } from 'src/utils/isBot';
const propTypes = {
id: PropTypes.string.isRequired,
@@ -61,6 +63,7 @@ class Row extends React.PureComponent {
super(props);
this.state = {
isFocused: false,
+ isInView: false,
};
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
@@ -69,6 +72,50 @@ class Row extends React.PureComponent {
'background',
);
this.handleChangeFocus = this.handleChangeFocus.bind(this);
+
+ this.containerRef = React.createRef();
+ this.observerEnabler = null;
+ this.observerDisabler = null;
+ }
+
+ // if chart not rendered - render it if it's less than 1 view height away from current viewport
+ // if chart rendered - remove it if it's more than 4 view heights away from current viewport
+ componentDidMount() {
+ if (
+ isFeatureEnabled(FeatureFlag.DASHBOARD_VIRTUALIZATION) &&
+ !isCurrentUserBot()
+ ) {
+ this.observerEnabler = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && !this.state.isInView) {
+ this.setState({ isInView: true });
+ }
+ },
+ {
+ rootMargin: '100% 0px',
+ },
+ );
+ this.observerDisabler = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry.isIntersecting && this.state.isInView) {
+ this.setState({ isInView: false });
+ }
+ },
+ {
+ rootMargin: '400% 0px',
+ },
+ );
+ const element = this.containerRef.current;
+ if (element) {
+ this.observerEnabler.observe(element);
+ this.observerDisabler.observe(element);
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this.observerEnabler?.disconnect();
+ this.observerDisabler?.disconnect();
}
handleChangeFocus(nextFocus) {
@@ -161,6 +208,7 @@ class Row extends React.PureComponent {
backgroundStyle.className,
)}
data-test={`grid-row-${backgroundStyle.className}`}
+ ref={this.containerRef}
>
{rowItems.map((componentId, itemIndex) => (
))}
diff --git a/superset-frontend/src/utils/isBot.ts b/superset-frontend/src/utils/isBot.ts
new file mode 100644
index 0000000000000..8509a43c85c9e
--- /dev/null
+++ b/superset-frontend/src/utils/isBot.ts
@@ -0,0 +1,21 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// navigator.webdriver is true when browser is controlled by a bot
+export const isCurrentUserBot = () => window?.navigator?.webdriver;
diff --git a/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts b/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts
new file mode 100644
index 0000000000000..f27b8c849f3c5
--- /dev/null
+++ b/superset-frontend/src/utils/isDashboardVirtualizationEnabled.ts
@@ -0,0 +1,30 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export enum DASHBOARD_VIRTUALIZATION_MODE {
+ NONE = 'NONE',
+ VIEWPORT = 'VIEWPORT',
+ PAGINATED = 'PAGINATED',
+}
+
+export const isDashboardVirtualizationEnabled = (
+ virtualizationMode: DASHBOARD_VIRTUALIZATION_MODE,
+) =>
+ virtualizationMode === DASHBOARD_VIRTUALIZATION_MODE.VIEWPORT ||
+ virtualizationMode === DASHBOARD_VIRTUALIZATION_MODE.PAGINATED;
diff --git a/superset/config.py b/superset/config.py
index a7b7e655ecbb9..aec2dba890c0b 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -425,6 +425,7 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]:
# Feature is under active development and breaking changes are expected
"DASHBOARD_NATIVE_FILTERS_SET": False,
"DASHBOARD_FILTERS_EXPERIMENTAL": False,
+ "DASHBOARD_VIRTUALIZATION": False,
"GLOBAL_ASYNC_QUERIES": False,
"VERSIONED_EXPORT": True,
"EMBEDDED_SUPERSET": False,
@@ -761,7 +762,6 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]:
# Force refresh while auto-refresh in dashboard
DASHBOARD_AUTO_REFRESH_MODE: Literal["fetch", "force"] = "force"
-
# Default celery config is to use SQLA as a broker, in a production setting
# you'll want to use a proper broker as specified here:
# http://docs.celeryproject.org/en/latest/getting-started/brokers/index.html
diff --git a/superset/views/base.py b/superset/views/base.py
index b790ca709c261..cf7868eaf1c04 100644
--- a/superset/views/base.py
+++ b/superset/views/base.py
@@ -103,6 +103,7 @@
"SQLALCHEMY_DISPLAY_TEXT",
"GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL",
"DASHBOARD_AUTO_REFRESH_MODE",
+ "DASHBOARD_VIRTUALIZATION",
"SCHEDULED_QUERIES",
"EXCEL_EXTENSIONS",
"CSV_EXTENSIONS",