You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/22 16:12:52 UTC
(superset) branch master updated: fix(matrixify): lazy-load grid cells with dashboard virtualization (#43053)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 47fd4f4cbfc fix(matrixify): lazy-load grid cells with dashboard virtualization (#43053)
47fd4f4cbfc is described below
commit 47fd4f4cbfc16af7666d1f4e77b16d194a69c10b
Author: jenwitteng <je...@agoda.com>
AuthorDate: Tue Sep 22 23:12:43 2026 +0700
fix(matrixify): lazy-load grid cells with dashboard virtualization (#43053)
Co-authored-by: Evan Rusackas <ev...@preset.io>
---
.../Matrixify/MatrixifyGridCell.test.tsx | 273 ++++++++++++++++++++-
.../components/Matrixify/MatrixifyGridCell.tsx | 127 +++++++++-
.../components/Matrixify/virtualizationEvents.ts | 82 +++++++
3 files changed, 470 insertions(+), 12 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.test.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.test.tsx
index 1a962b25530..f3ebe722af6 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.test.tsx
@@ -17,11 +17,16 @@
* under the License.
*/
-import { render, screen } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ThemeProvider, supersetTheme } from '@apache-superset/core/theme';
+import { FeatureFlag } from '../../../utils/featureFlags';
import MatrixifyGridCell from './MatrixifyGridCell';
import { MatrixifyGridCell as MatrixifyGridCellType } from '../../types/matrixify';
+import {
+ FORCE_IN_VIEW_EVENT,
+ RESTORE_VIRTUALIZATION_EVENT,
+} from './virtualizationEvents';
// Mock StatefulChart component
jest.mock('../StatefulChart', () => {
@@ -44,6 +49,74 @@ jest.mock('../StatefulChart', () => {
};
});
+// Controllable IntersectionObserver mock so lazy-loading can be driven in tests
+type IntersectionCallback = (
+ entries: Array<{ isIntersecting: boolean }>,
+) => void;
+let intersectionCallbacks: IntersectionCallback[] = [];
+let disconnectCount = 0;
+
+class MockIntersectionObserver {
+ constructor(callback: IntersectionCallback) {
+ intersectionCallbacks.push(callback);
+ }
+
+ observe = () => {};
+
+ unobserve = () => {};
+
+ disconnect = () => {
+ disconnectCount += 1;
+ };
+
+ takeRecords = () => [];
+}
+
+const triggerIntersection = (isIntersecting: boolean) => {
+ act(() => {
+ intersectionCallbacks.forEach(cb => cb([{ isIntersecting }]));
+ });
+};
+
+const enableVirtualization = () => {
+ window.featureFlags = { [FeatureFlag.DashboardVirtualization]: true };
+};
+
+const setHeadlessCapture = (value: boolean) => {
+ Object.defineProperty(window.navigator, 'webdriver', {
+ value,
+ configurable: true,
+ });
+};
+
+// Dispatches FORCE_IN_VIEW_EVENT, optionally scoped to a batch of dashboard
+// row ids the way src/utils/downloadUtils.ts does for large dashboards.
+const dispatchForceInView = (rowIds?: string[]) => {
+ act(() => {
+ window.dispatchEvent(
+ rowIds
+ ? new CustomEvent(FORCE_IN_VIEW_EVENT, { detail: { rowIds } })
+ : new Event(FORCE_IN_VIEW_EVENT),
+ );
+ });
+};
+
+beforeEach(() => {
+ intersectionCallbacks = [];
+ disconnectCount = 0;
+ (window as any).IntersectionObserver = MockIntersectionObserver;
+});
+
+afterEach(() => {
+ window.featureFlags = {};
+ setHeadlessCapture(false);
+ // Reset the module-level force-in-view state so it doesn't leak between
+ // tests (it is intentionally shared module state, mirroring production).
+ act(() => {
+ window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
+ });
+});
+
const mockDatasource = {
id: 1,
type: 'table',
@@ -79,6 +152,23 @@ const defaultProps = {
const renderWithTheme = (component: React.ReactElement) =>
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
+// Renders the cell inside a stand-in for dashboard Row.tsx's DOM node, which
+// carries a `data-row-id` attribute that MatrixifyGridCell walks up to via
+// `closest()` to scope itself to the right FORCE_IN_VIEW_EVENT batch. A null
+// rowId renders with no such ancestor, matching a Matrixify chart previewed
+// in Explore (no dashboard rows at all).
+const renderCellInRow = (
+ rowId: string | null,
+ component: React.ReactElement,
+) => {
+ const themed = (
+ <ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>
+ );
+ return rowId === null
+ ? render(themed)
+ : render(<div data-row-id={rowId}>{themed}</div>);
+};
+
test('should render the cell with title', () => {
renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
@@ -210,3 +300,184 @@ test('should handle empty cell data gracefully', () => {
// Should still render but with empty title
expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
});
+
+test('renders chart eagerly when virtualization is disabled', () => {
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('defers chart mount until the cell scrolls into view when virtualization is enabled', () => {
+ enableVirtualization();
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ // Before intersecting, only a placeholder is rendered - no query fires
+ expect(screen.queryByText('SuperChart Mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+
+ // Once the cell enters the viewport the chart mounts
+ triggerIntersection(true);
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('keeps the chart mounted after it has entered view (latch)', () => {
+ enableVirtualization();
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ triggerIntersection(true);
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ // Observer is disconnected after the first intersection
+ expect(disconnectCount).toBeGreaterThan(0);
+
+ // Scrolling back out of view must not unmount / refetch the chart
+ triggerIntersection(false);
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+});
+
+test('renders eagerly when the browser has no IntersectionObserver support', () => {
+ enableVirtualization();
+ delete (window as any).IntersectionObserver;
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ // No way to detect visibility - fall back to rendering immediately rather
+ // than leaving the cell stuck on its placeholder forever.
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('renders every cell eagerly during headless capture even with virtualization on', () => {
+ enableVirtualization();
+ setHeadlessCapture(true);
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ // Screenshot/PDF/report workers must capture all cells regardless of scroll
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('mounts an off-screen cell when an unscoped FORCE_IN_VIEW_EVENT fires (client-side download as image/PDF)', () => {
+ enableVirtualization();
+
+ // No ancestor row - e.g. a small dashboard, where downloadUtils.ts has
+ // nothing to batch and dispatches a single unscoped event instead.
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ expect(screen.queryByText('SuperChart Mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+
+ dispatchForceInView();
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('mounts when FORCE_IN_VIEW_EVENT is scoped to its own dashboard row', () => {
+ enableVirtualization();
+
+ renderCellInRow('row-1', <MatrixifyGridCell {...defaultProps} />);
+
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+
+ // downloadUtils.ts batches large dashboards by row and scopes each
+ // dispatch's detail.rowIds to just that batch.
+ dispatchForceInView(['row-1', 'row-2']);
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+});
+
+test('does not mount when FORCE_IN_VIEW_EVENT is scoped to a different dashboard row', () => {
+ enableVirtualization();
+
+ renderCellInRow('row-3', <MatrixifyGridCell {...defaultProps} />);
+
+ // A batch targeting other rows must not force this cell's queries to fire;
+ // otherwise a Matrixify chart would defeat downloadUtils.ts's row batching
+ // by dumping every deferred cell's query into whichever batch fires first.
+ dispatchForceInView(['row-1', 'row-2']);
+
+ expect(screen.queryByText('SuperChart Mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+
+ // It still mounts once its own row's batch is dispatched.
+ dispatchForceInView(['row-3']);
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+});
+
+test('renders immediately when first mounted while its own row is already forced into view', () => {
+ // Simulates a matrix whose parent dashboard Row is below the fold: with
+ // DASHBOARD_VIRTUALIZATION on, the Row (and everything inside it,
+ // including this cell) is unmounted until FORCE_IN_VIEW_EVENT causes the
+ // Row to mount it - which happens *after* that batch's event has already
+ // been dispatched, so the cell only exists once it mounts.
+ enableVirtualization();
+
+ dispatchForceInView(['row-1', 'row-2']);
+
+ renderCellInRow('row-1', <MatrixifyGridCell {...defaultProps} />);
+
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+ expect(
+ screen.queryByTestId('matrixify-cell-placeholder'),
+ ).not.toBeInTheDocument();
+});
+
+test('does not render immediately when first mounted while a different row is being forced into view', () => {
+ enableVirtualization();
+
+ dispatchForceInView(['row-1', 'row-2']);
+
+ // This cell's own row (row-3) was not part of that batch, so it must stay
+ // lazy until its own batch is dispatched (or it scrolls into view).
+ renderCellInRow('row-3', <MatrixifyGridCell {...defaultProps} />);
+
+ expect(screen.queryByText('SuperChart Mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+});
+
+test('re-arms lazy loading for newly-mounted cells after RESTORE_VIRTUALIZATION_EVENT', () => {
+ enableVirtualization();
+
+ dispatchForceInView();
+ act(() => {
+ window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
+ });
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ // A cell mounting after virtualization has been restored should go back
+ // to lazy-loading behavior rather than staying permanently forced on.
+ expect(screen.queryByText('SuperChart Mock')).not.toBeInTheDocument();
+ expect(screen.getByTestId('matrixify-cell-placeholder')).toBeInTheDocument();
+});
+
+test('does not unmount after RESTORE_VIRTUALIZATION_EVENT once forced into view (latch)', () => {
+ enableVirtualization();
+
+ renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
+
+ dispatchForceInView();
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+
+ act(() => {
+ window.dispatchEvent(new Event(RESTORE_VIRTUALIZATION_EVENT));
+ });
+ expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
+});
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.tsx
index ddcb7a8d86c..8ef8d73f7c7 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/MatrixifyGridCell.tsx
@@ -17,11 +17,30 @@
* under the License.
*/
-import { memo, useMemo } from 'react';
+import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
+import { FeatureFlag, isFeatureEnabled } from '../../../utils/featureFlags';
import { MatrixifyGridCell as GridCellData } from '../../types/matrixify';
import StatefulChart from '../StatefulChart';
+import {
+ FORCE_IN_VIEW_EVENT,
+ isForceInViewActiveForRow,
+} from './virtualizationEvents';
+
+// How far outside the viewport a cell can be before it starts loading. Keeps a
+// small buffer so charts finish loading just before the user scrolls to them.
+const LAZY_LOAD_ROOT_MARGIN = '200px 0px';
+
+// A headless browser (e.g. the server-side screenshot/report/thumbnail
+// worker) sets navigator.webdriver. In that case every cell must render
+// immediately so that captures include the full matrix regardless of scroll
+// position. This does NOT cover client-side "Download as Image/PDF", which
+// runs in the user's normal (non-headless) browser; that path instead relies
+// on FORCE_IN_VIEW_EVENT (see the effect below) to force off-screen cells to
+// mount before the capture is taken.
+const isHeadlessCapture = () =>
+ typeof window !== 'undefined' && Boolean(window.navigator?.webdriver);
const CellContainer = styled.div`
height: 100%;
@@ -75,6 +94,13 @@ const NoDataMessage = styled.div<{ theme: any }>`
user-select: none;
`;
+// Reserves the cell's space while the chart has not yet scrolled into view, so
+// the grid layout stays stable and no data query is fired for off-screen cells.
+const ChartPlaceholder = styled.div`
+ width: 100%;
+ height: 100%;
+`;
+
interface MatrixifyGridCellProps {
cell: GridCellData;
rowHeight: number;
@@ -99,6 +125,80 @@ const MatrixifyGridCell = memo(
// Only show label if it has content
const showLabel = cellLabel && cellLabel.trim() !== '';
+ // Per-cell lazy loading: when dashboard virtualization is enabled, defer
+ // mounting the chart (and therefore its data query) until the cell scrolls
+ // into view. This prevents a large matrix from firing every cell's query at
+ // once. Disabled flag or headless capture renders immediately.
+ const containerRef = useRef<HTMLDivElement>(null);
+ const lazyLoadEnabled =
+ isFeatureEnabled(FeatureFlag.DashboardVirtualization) &&
+ !isHeadlessCapture();
+ // Latch: once a cell has entered the viewport it stays mounted, avoiding
+ // refetch churn when scrolling back and forth within the grid.
+ const [hasEnteredView, setHasEnteredView] = useState(!lazyLoadEnabled);
+
+ useEffect(() => {
+ if (hasEnteredView) {
+ return undefined;
+ }
+ const element = containerRef.current;
+ if (!element || typeof IntersectionObserver === 'undefined') {
+ // No element or no observer support: fall back to rendering eagerly.
+ setHasEnteredView(true);
+ return undefined;
+ }
+ const observer = new IntersectionObserver(
+ entries => {
+ if (entries.some(entry => entry.isIntersecting)) {
+ setHasEnteredView(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: LAZY_LOAD_ROOT_MARGIN },
+ );
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [hasEnteredView]);
+
+ // Client-side "Download as Image/PDF" runs in the user's normal
+ // (non-headless) browser, so isHeadlessCapture() above never applies. It
+ // instead forces lazily-loaded charts to mount by dispatching
+ // FORCE_IN_VIEW_EVENT on window - optionally scoped to a batch of
+ // dashboard row ids, see src/utils/downloadUtils.ts - then waits for
+ // loading spinners to clear before capturing. Mirror the same row
+ // scoping dashboard Row.tsx uses (via the nearest ancestor
+ // [data-row-id]) so a Matrixify chart mounts in step with its own row's
+ // batch instead of dumping every deferred cell's query into whichever
+ // batch happens to fire first.
+ useEffect(() => {
+ if (!lazyLoadEnabled || hasEnteredView) {
+ return undefined;
+ }
+ const ancestorRowId =
+ containerRef.current
+ ?.closest('[data-row-id]')
+ ?.getAttribute('data-row-id') ?? null;
+ if (isForceInViewActiveForRow(ancestorRowId)) {
+ setHasEnteredView(true);
+ return undefined;
+ }
+ const handleForceInView = (event: Event) => {
+ const rowIds = (event as CustomEvent<{ rowIds?: string[] }>).detail
+ ?.rowIds;
+ if (
+ rowIds &&
+ (ancestorRowId === null || !rowIds.includes(ancestorRowId))
+ ) {
+ return;
+ }
+ setHasEnteredView(true);
+ };
+ window.addEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
+ return () => {
+ window.removeEventListener(FORCE_IN_VIEW_EVENT, handleForceInView);
+ };
+ }, [lazyLoadEnabled, hasEnteredView]);
+
// Create enhanced hooks that merge cell filters with drill filters
const enhancedHooks = useMemo(() => {
if (!hooks) return undefined;
@@ -141,6 +241,7 @@ const MatrixifyGridCell = memo(
return (
<CellContainer
+ ref={containerRef}
className="matrixify-cell"
data-row={cell.row}
data-col={cell.col}
@@ -149,16 +250,20 @@ const MatrixifyGridCell = memo(
>
{showLabel && <CellHeader title={cellLabel}>{cellLabel}</CellHeader>}
<ChartWrapper>
- <StatefulChart
- id={cell.id}
- formData={cell.formData}
- width="100%"
- height="100%"
- enableNoResults
- noDataComponent={MatrixNoDataComponent}
- showLoading
- hooks={enhancedHooks}
- />
+ {hasEnteredView ? (
+ <StatefulChart
+ id={cell.id}
+ formData={cell.formData}
+ width="100%"
+ height="100%"
+ enableNoResults
+ noDataComponent={MatrixNoDataComponent}
+ showLoading
+ hooks={enhancedHooks}
+ />
+ ) : (
+ <ChartPlaceholder data-test="matrixify-cell-placeholder" />
+ )}
</ChartWrapper>
</CellContainer>
);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/virtualizationEvents.ts b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/virtualizationEvents.ts
new file mode 100644
index 00000000000..b58dc54eff4
--- /dev/null
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/Matrixify/virtualizationEvents.ts
@@ -0,0 +1,82 @@
+/**
+ * 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.
+ */
+
+/**
+ * Mirrors FORCE_IN_VIEW_EVENT / RESTORE_VIRTUALIZATION_EVENT from
+ * src/dashboard/constants.ts. Duplicated (rather than imported) because
+ * packages/superset-ui-core cannot depend on app code under src/dashboard;
+ * the string values must stay in sync with that file.
+ *
+ * Anything that lazily mounts a chart based on viewport visibility (e.g.
+ * dashboard Row.tsx, MatrixifyGridCell) needs to listen for this event and
+ * mount immediately when it fires, since client-side "Download as
+ * Image/PDF" (see src/utils/downloadUtils.ts) dispatches it - optionally
+ * scoped to a batch of dashboard row ids via `detail.rowIds` - to force
+ * charts into view before capturing the page.
+ */
+export const FORCE_IN_VIEW_EVENT = 'superset-force-all-in-view';
+export const RESTORE_VIRTUALIZATION_EVENT = 'superset-restore-virtualization';
+
+/**
+ * Module-level mirror of "which dashboard rows are currently forced into
+ * view", in addition to the events themselves.
+ *
+ * Why this is needed: FORCE_IN_VIEW_EVENT is dispatched once per batch,
+ * synchronously. A component that's already mounted when it fires (e.g.
+ * dashboard Row.tsx, which stays mounted even when scrolled out of view)
+ * can react to it directly via its own listener. But when
+ * DASHBOARD_VIRTUALIZATION is on, a Row below the fold is unmounted
+ * entirely, so forcing it into view causes it - and everything inside it,
+ * including MatrixifyGridCell instances - to mount for the first time on a
+ * later render. Those newly-mounted components register their event
+ * listener in a passive effect that runs after the event has already been
+ * dispatched and returned, so they would never see it. Tracking the
+ * "currently forced" state at module scope lets a component read the
+ * current state synchronously as soon as it mounts, regardless of whether
+ * it existed yet when the event fired.
+ *
+ * - undefined: no force-in-view export is in progress.
+ * - null: a force-in-view export is in progress and unscoped (every row).
+ * - Set<string>: scoped to just these dashboard row ids, matching the
+ * batched dispatch in src/utils/downloadUtils.ts.
+ */
+let activeForceRowIds: Set<string> | null | undefined;
+
+if (typeof window !== 'undefined') {
+ window.addEventListener(FORCE_IN_VIEW_EVENT, event => {
+ const rowIds = (event as CustomEvent<{ rowIds?: string[] }>).detail?.rowIds;
+ activeForceRowIds = rowIds ? new Set(rowIds) : null;
+ });
+ window.addEventListener(RESTORE_VIRTUALIZATION_EVENT, () => {
+ activeForceRowIds = undefined;
+ });
+}
+
+/**
+ * Whether a force-in-view export is currently active for the given ancestor
+ * dashboard row id (or for content with no ancestor row at all, e.g. a
+ * Matrixify chart rendered in Explore). Mirrors the scoping check dashboard
+ * Row.tsx applies to the live event: an unscoped force (no `rowIds`) always
+ * matches.
+ */
+export const isForceInViewActiveForRow = (rowId: string | null): boolean => {
+ if (activeForceRowIds === undefined) return false;
+ if (activeForceRowIds === null) return true;
+ return rowId !== null && activeForceRowIds.has(rowId);
+};