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);
+};