From 29bc991acf9f11641522174b32cfc80701d73944 Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Fri, 25 Sep 2026 21:52:45 -0700 Subject: [PATCH 1/2] perf(dashboard): opt in to initial off-screen panel queries --- public/app/core/reducers/fn-slice.test.ts | 7 +- public/app/core/reducers/fn-slice.ts | 5 ++ .../dashboard/dashgrid/DashboardGrid.test.tsx | 88 +++++++++++++++++-- .../dashboard/dashgrid/DashboardGrid.tsx | 6 ++ .../dashboard/dashgrid/DashboardPanel.tsx | 12 ++- .../dashboard/dashgrid/LazyLoader.test.tsx | 64 ++++++++++++++ .../dashboard/dashgrid/LazyLoader.tsx | 20 ++++- .../dashgrid/PanelStateWrapper.test.tsx | 87 ++++++++++++++++-- .../dashboard/dashgrid/PanelStateWrapper.tsx | 7 +- .../fn-dashboard-page/fn-dashboard.test.tsx | 29 +++++- 10 files changed, 301 insertions(+), 24 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/LazyLoader.test.tsx diff --git a/public/app/core/reducers/fn-slice.test.ts b/public/app/core/reducers/fn-slice.test.ts index ce22d930515c2..13fe09c3adbf4 100644 --- a/public/app/core/reducers/fn-slice.test.ts +++ b/public/app/core/reducers/fn-slice.test.ts @@ -1,7 +1,12 @@ -import { fnStateProps } from './fn-slice'; +import { fnStateProps, INITIAL_FN_STATE } from './fn-slice'; describe('fn-slice', () => { it('includes the host portal container id in copied microfrontend state props', () => { expect(fnStateProps).toContain('portalContainerID'); }); + + it('copies the explicit panel preload opt-in and leaves it disabled by default', () => { + expect(fnStateProps).toContain('preloadPanels'); + expect(INITIAL_FN_STATE.preloadPanels).toBe(false); + }); }); diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 2f2edfa6ac589..2cd51f7141851 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -22,6 +22,8 @@ export interface FnState { version: number; controlsContainer: string | null; dashboardAccessMode: 'standard' | 'custom'; + /** Host opt-in for eager canned-panel loading; never grants query access. */ + preloadPanels?: boolean; pageTitle: string; queryParams: AnyObject; hiddenVariables: string[]; @@ -76,6 +78,7 @@ export type FnPropMappedFromState = Extract< | 'version' | 'controlsContainer' | 'dashboardAccessMode' + | 'preloadPanels' | 'enablePanelEdit' | 'enablePanelDelete' | 'enablePanelLayoutEdit' @@ -96,6 +99,7 @@ export type FnPropsMappedFromState = Pick; export const fnStateProps: FnStateProp[] = [ 'controlsContainer', 'dashboardAccessMode', + 'preloadPanels', 'enablePanelEdit', 'enablePanelDelete', 'enablePanelLayoutEdit', @@ -123,6 +127,7 @@ export const INITIAL_FN_STATE: FnState = { version: 1, controlsContainer: null, dashboardAccessMode: 'standard', + preloadPanels: false, pageTitle: '', queryParams: {}, hiddenVariables: [], diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index d0be2c3ef2f48..a5f9ae9ed824d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -1,25 +1,40 @@ import { act, render, screen } from '@testing-library/react'; +import { ReactNode } from 'react'; import { Provider } from 'react-redux'; import { Router } from 'react-router-dom'; import { useEffectOnce } from 'react-use'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; -import { TextBoxVariableModel } from '@grafana/data'; +import { GrafanaThemeType, TextBoxVariableModel } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import appEvents from 'app/core/app_events'; import { GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; +import { INITIAL_FN_STATE } from 'app/core/reducers/fn-slice'; import { GetVariables } from 'app/features/variables/state/selectors'; import { VariablesChanged } from 'app/features/variables/types'; import { configureStore } from 'app/store/configureStore'; -import { DashboardMeta } from 'app/types'; +import { DashboardMeta, StoreState } from 'app/types'; import { DashboardModel, PanelModel } from '../state'; import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures'; import { Component, DashboardGrid, Props } from './DashboardGrid'; -import { Props as LazyLoaderProps } from './LazyLoader'; +import { LazyLoader, Props as LazyLoaderProps } from './LazyLoader'; + +// A bundled panel's package mock omits SceneDataLayerBase; this suite exercises +// the real dashboard graph, not that plugin's isolated scene stub. +jest.unmock('@grafana/scenes'); +jest.unmock('@grafana/data'); +jest.unmock('@grafana/ui'); + +// JSDOM has no layout; provide the viewport the grid needs to mount its panels. +jest.mock('react-virtualized-auto-sizer', () => ({ + __esModule: true, + default: ({ children }: { children: (size: { width: number; height: number }) => ReactNode }) => + children({ width: 1000, height: 800 }), +})); jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -32,18 +47,33 @@ jest.mock('@grafana/runtime', () => ({ })); jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => { - const LazyLoader = ({ children, onLoad }: Pick) => { + const LazyLoader = jest.fn(({ children, onLoad }: Pick) => { useEffectOnce(() => { onLoad?.(); }); return <>{typeof children === 'function' ? children({ isInView: true }) : children}; - }; + }); return { LazyLoader }; }); -function setup(props: Props) { +function setup(props: Props, initialState: Partial = {}) { const context = getGrafanaContextMock(); - const store = configureStore({}); + const store = configureStore({ + fnGlobalState: { + ...INITIAL_FN_STATE, + FNDashboard: props.isFnDashboard ?? false, + mode: GrafanaThemeType.Light, + portalContainerID: props.portalContainerID ?? INITIAL_FN_STATE.portalContainerID, + }, + ...initialState, + }); + const container = document.createElement('div'); + container.id = props.portalContainerID ?? ''; + Object.defineProperties(container, { + clientWidth: { value: 1000 }, + clientHeight: { value: 800 }, + }); + document.body.appendChild(container); return render( @@ -52,7 +82,8 @@ function setup(props: Props) { - + , + { container } ); } @@ -122,6 +153,47 @@ function getTestDashboard( } describe('DashboardGrid', () => { + let fetchSpy: jest.SpyInstance; + beforeAll(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.endsWith('.svg')) { + throw new Error(`Unexpected grid test fetch: ${url}`); + } + return new Response(''); + }); + }); + afterAll(() => fetchSpy.mockRestore()); + + it.each([ + { FNDashboard: true, dashboardAccessMode: 'standard' as const, preloadPanels: true, lazy: false }, + { FNDashboard: true, dashboardAccessMode: 'standard' as const, preloadPanels: false, lazy: true }, + { FNDashboard: true, dashboardAccessMode: 'custom' as const, preloadPanels: true, lazy: true }, + { FNDashboard: false, dashboardAccessMode: 'standard' as const, preloadPanels: true, lazy: true }, + ])('only preloads explicitly opted-in embedded standard dashboards: %j', async ({ lazy, ...fnState }) => { + jest.mocked(LazyLoader).mockClear(); + setup( + { editPanel: null, viewPanel: null, isEditable: false, dashboard: getTestDashboard() }, + { fnGlobalState: { ...INITIAL_FN_STATE, mode: GrafanaThemeType.Light, ...fnState } } + ); + expect(await screen.findByText('My gauge')).toBeInTheDocument(); + expect(jest.mocked(LazyLoader).mock.calls.length).toBeGreaterThan(0); + expect(jest.mocked(LazyLoader).mock.calls.every(([props]) => Boolean(props.preload) === !lazy)).toBe(true); + }); + + it.each([undefined, false, true])('passes preload=%s to the existing panel loading path', (preloadPanels) => { + const dashboard = getTestDashboard(); + const grid = new Component({ + editPanel: null, + viewPanel: null, + isEditable: false, + dashboard, + preloadPanels, + }); + const rendered = grid.renderPanel(getRequiredPanel(dashboard, 4), 800, 300, false); + expect(rendered.props.preload).toBe(preloadPanels); + }); + it('Should render panels', async () => { const props: Props = { editPanel: null, diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx index ace10d708bb2d..92f9a5599a586 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.tsx @@ -27,6 +27,7 @@ export interface Props { viewPanel: PanelModel | null; hidePanelMenus?: boolean; isFnDashboard?: boolean; + preloadPanels?: boolean; onLayoutUpdate?: () => void; portalContainerID?: string; } @@ -453,6 +454,7 @@ export class Component extends PureComponent { width={width} height={height} hideMenu={this.props.hidePanelMenus} + preload={this.props.preloadPanels} /> ); } @@ -624,6 +626,10 @@ GrafanaGridItem.displayName = 'GridItemWithDimensions'; function mapStateToProps() { return (state: StoreState) => ({ isFnDashboard: state.fnGlobalState.FNDashboard, + preloadPanels: + state.fnGlobalState.FNDashboard && + state.fnGlobalState.dashboardAccessMode === 'standard' && + state.fnGlobalState.preloadPanels === true, portalContainerID: state.fnGlobalState.portalContainerID, }); } diff --git a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx index b2c34f0b392b8..aedcd6248cd6f 100644 --- a/public/app/features/dashboard/dashgrid/DashboardPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardPanel.tsx @@ -21,6 +21,7 @@ export interface OwnProps { width: number; height: number; lazy?: boolean; + preload?: boolean; timezone?: string; hideMenu?: boolean; } @@ -114,6 +115,7 @@ export class DashboardPanelUnconnected extends PureComponent { isViewing={isViewing} isEditing={isEditing} isInView={isInView} + preloadInitialQuery={this.props.preload} isDraggable={isDraggable} width={width} height={height} @@ -125,10 +127,16 @@ export class DashboardPanelUnconnected extends PureComponent { }; render() { - const { width, height, lazy } = this.props; + const { width, height, lazy, preload } = this.props; return lazy ? ( - + {this.renderPanel} ) : ( diff --git a/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx b/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx new file mode 100644 index 0000000000000..8d1f0e78cc079 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx @@ -0,0 +1,64 @@ +import { act, render, screen } from '@testing-library/react'; + +import { LazyLoader } from './LazyLoader'; + +describe('LazyLoader preload', () => { + beforeEach(() => { + jest.spyOn(LazyLoader.observer, 'observe').mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('accepts a later host opt-in without repeating the load or losing visibility', () => { + const onLoad = jest.fn(); + const child = ({ isInView }: { isInView: boolean }) => {isInView ? 'visible' : 'off-screen'}; + const { rerender } = render({child}); + expect(onLoad).not.toHaveBeenCalled(); + rerender( + + {child} + + ); + expect(screen.getByText('off-screen')).toBeInTheDocument(); + expect(onLoad).toHaveBeenCalledTimes(1); + rerender( + + {child} + + ); + rerender( + + {child} + + ); + expect(screen.getByText('off-screen')).toBeInTheDocument(); + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it.each([false, true])('keeps actual visibility and loads only once with preload=%s', (preload) => { + const onLoad = jest.fn(); + const onChange = jest.fn(); + const unobserve = jest.spyOn(LazyLoader.observer, 'unobserve'); + const { container, unmount } = render( + + {({ isInView }) => {isInView ? 'visible panel' : 'off-screen panel'}} + + ); + expect(onLoad).toHaveBeenCalledTimes(preload ? 1 : 0); + expect(screen.queryByText('off-screen panel') !== null).toBe(preload); + const element = container.firstElementChild!; + const changeVisibility = (isIntersecting: boolean) => { + act(() => LazyLoader.callbacks[element.id]({ target: element, isIntersecting } as IntersectionObserverEntry)); + }; + changeVisibility(true); + expect(screen.getByText('visible panel')).toBeInTheDocument(); + changeVisibility(false); + expect(screen.getByText('off-screen panel')).toBeInTheDocument(); + changeVisibility(true); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls).toEqual([[true], [false], [true]]); + unmount(); + expect(unobserve).toHaveBeenCalledWith(element); + expect(LazyLoader.callbacks[element.id]).toBeUndefined(); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/LazyLoader.tsx b/public/app/features/dashboard/dashgrid/LazyLoader.tsx index 1472acda3d657..d75de10c27537 100644 --- a/public/app/features/dashboard/dashgrid/LazyLoader.tsx +++ b/public/app/features/dashboard/dashgrid/LazyLoader.tsx @@ -1,4 +1,4 @@ -import { useId, useRef, useState } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { useEffectOnce } from 'react-use'; export interface Props { @@ -7,17 +7,21 @@ export interface Props { height?: number; onLoad?: () => void; onChange?: (isInView: boolean) => void; + /** Mount initially, while retaining actual viewport visibility for refreshes. */ + preload?: boolean; } -export function LazyLoader({ children, width, height, onLoad, onChange }: Props) { +export function LazyLoader({ children, width, height, onLoad, onChange, preload = false }: Props) { const id = useId(); const [loaded, setLoaded] = useState(false); + const hasLoaded = useRef(false); const [isInView, setIsInView] = useState(false); const wrapperRef = useRef(null); useEffectOnce(() => { LazyLoader.addCallback(id, (entry) => { - if (!loaded && entry.isIntersecting) { + if (!hasLoaded.current && entry.isIntersecting) { + hasLoaded.current = true; setLoaded(true); onLoad?.(); } @@ -41,6 +45,16 @@ export function LazyLoader({ children, width, height, onLoad, onChange }: Props) }; }); + useEffect(() => { + // Host settings may reach an existing dashboard store after its first render. + // Do not remount or reload panels that already entered the viewport. + if (preload && !hasLoaded.current) { + hasLoaded.current = true; + setLoaded(true); + onLoad?.(); + } + }, [preload, onLoad]); + return (
{loaded && (typeof children === 'function' ? children({ isInView }) : children)} diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx index d37ebdc1af791..35a64ae82051f 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.test.tsx @@ -1,10 +1,13 @@ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { ReplaySubject } from 'rxjs'; import { EventBusSrv, getDefaultTimeRange, LoadingState, PanelData, PanelPlugin } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { RefreshEvent } from '@grafana/runtime'; +import { INITIAL_FN_STATE } from 'app/core/reducers/fn-slice'; import { PanelQueryRunner } from '../../query/state/PanelQueryRunner'; import { setTimeSrv, TimeSrv } from '../services/TimeSrv'; @@ -12,24 +15,30 @@ import { DashboardModel, PanelModel } from '../state'; import { PanelStateWrapper, Props } from './PanelStateWrapper'; +jest.unmock('@grafana/scenes'); +jest.unmock('@grafana/data'); +jest.unmock('@grafana/ui'); +jest.unmock('@grafana/runtime'); + jest.mock('app/core/profiler', () => ({ profiler: { renderingCompleted: jest.fn(), }, })); -function setupTestContext(options: Partial) { +function setupTestContext(options: Partial, initializePanel = false) { const mockStore = configureMockStore(); - const store = mockStore({ dashboard: { panels: [] } }); + const store = mockStore({ dashboard: { panels: [] }, fnGlobalState: { ...INITIAL_FN_STATE, FNDashboard: false } }); const subject: ReplaySubject = new ReplaySubject(); const panelQueryRunner = { + getLastResult: () => undefined, getData: () => subject, run: () => { subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); }, } as unknown as PanelQueryRunner; const timeSrv = { - timeRange: jest.fn(), + timeRange: jest.fn(() => getDefaultTimeRange()), } as unknown as TimeSrv; setTimeSrv(timeSrv); @@ -44,7 +53,8 @@ function setupTestContext(options: Partial) { getDisplayTitle: jest.fn(), }), dashboard: { - panelInitialized: jest.fn(), + panelInitialized: initializePanel ? DashboardModel.prototype.panelInitialized : jest.fn(), + otherPanelInFullscreen: () => false, getTimezone: () => 'browser', events: new EventBusSrv(), canAddAnnotations: jest.fn(), @@ -73,12 +83,70 @@ function setupTestContext(options: Partial) { ); - // Needed so mocks work - props.panel.refreshWhenInView = false; + // Manual-event tests do not run the dashboard's initial refresh. + if (!initializePanel) { + props.panel.refreshWhenInView = false; + } return { rerender, props, subject, store }; } describe('PanelStateWrapper', () => { + let fetchSpy: jest.SpyInstance; + beforeAll(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (!url.endsWith('.svg')) { + throw new Error(`Unexpected panel test fetch: ${url}`); + } + return new Response(''); + }); + }); + afterAll(() => fetchSpy.mockRestore()); + + it('preloads on dashboard initialization without querying again when the panel becomes visible', async () => { + const run = jest.spyOn(PanelModel.prototype, 'runAllPanelQueries').mockImplementation(() => {}); + try { + const { rerender, props, store, subject } = setupTestContext({ preloadInitialQuery: true }, true); + expect(run).toHaveBeenCalledTimes(1); + act(() => { + subject.next({ state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() }); + }); + await act(async () => { + rerender( + + + + ); + }); + expect(screen.getByText(/plugin panel to render/i)).toBeInTheDocument(); + expect(run).toHaveBeenCalledTimes(1); + } finally { + run.mockRestore(); + } + }); + + it.each([false, true])('allows only the first off-screen query with preload=%s', (preloadInitialQuery) => { + const { rerender, props, store } = setupTestContext({ preloadInitialQuery }); + const run = jest.spyOn(props.panel, 'runAllPanelQueries').mockImplementation(() => {}); + const refresh = () => act(() => props.panel.events.publish(new RefreshEvent())); + refresh(); + expect(run).toHaveBeenCalledTimes(preloadInitialQuery ? 1 : 0); + refresh(); + refresh(); + expect(run).toHaveBeenCalledTimes(preloadInitialQuery ? 1 : 0); + expect(props.panel.refreshWhenInView).toBe(true); + rerender( + + + + ); + expect(run).toHaveBeenCalledTimes(preloadInitialQuery ? 2 : 1); + expect(props.panel.refreshWhenInView).toBe(false); + rerender(<>); + refresh(); + expect(run).toHaveBeenCalledTimes(preloadInitialQuery ? 2 : 1); + }); + describe('when the user scrolls by a panel so fast that it starts loading data but scrolls out of view', () => { it('then it should load the panel successfully when scrolled into view again', () => { const { rerender, props, subject, store } = setupTestContext({}); @@ -132,9 +200,12 @@ describe('PanelStateWrapper', () => { const button = screen.getByTestId(selectors.components.Panels.Panel.status('error')); expect(button).toBeInTheDocument(); await act(async () => { - fireEvent.focus(button); + await userEvent.hover(button); }); expect(await screen.findByText(scenario.expectedMessage)).toBeInTheDocument(); + await act(async () => { + await userEvent.unhover(button); + }); }); }); }); diff --git a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx index 7665eeb8ed85a..7a81061ba9713 100644 --- a/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx @@ -73,6 +73,8 @@ export interface Props { isViewing: boolean; isEditing: boolean; isInView: boolean; + /** Permit only the initial query off-screen; later refreshes remain lazy. */ + preloadInitialQuery?: boolean; isDraggable?: boolean; width: number; height: number; @@ -421,10 +423,12 @@ export class PanelStateWrapperDisConnected extends PureComponent { this.setState({ isFirstLoad, errorMessage, data, liveTime: undefined }); } + private hasRequestedQuery = false; + onRefresh = () => { const { dashboard, panel, isInView, width } = this.props; - if (!dashboard.snapshot && !isInView) { + if (!dashboard.snapshot && !isInView && (!this.props.preloadInitialQuery || this.hasRequestedQuery)) { panel.refreshWhenInView = true; return; } @@ -438,6 +442,7 @@ export class PanelStateWrapperDisConnected extends PureComponent { } panel.refreshWhenInView = false; + this.hasRequestedQuery = true; panel.runAllPanelQueries({ dashboardUID: dashboard.uid, dashboardTimezone: dashboard.getTimezone(), diff --git a/public/app/fn-app/fn-dashboard-page/fn-dashboard.test.tsx b/public/app/fn-app/fn-dashboard-page/fn-dashboard.test.tsx index 75f133b7735d8..b8253d2a78cc7 100644 --- a/public/app/fn-app/fn-dashboard-page/fn-dashboard.test.tsx +++ b/public/app/fn-app/fn-dashboard-page/fn-dashboard.test.tsx @@ -44,7 +44,10 @@ jest.mock('app/store/configureStore', () => { dashboard: (state = { getModel: () => null }, action: { type: string; model?: unknown }) => action.type === 'test/model' ? { getModel: () => action.model } : state, }, - middleware: (defaults) => defaults({ serializableCheck: false }), + // Keep mutation detection, but do not turn machine contention into a + // lifecycle test failure through Redux's development timing warning. + middleware: (defaults) => + defaults({ serializableCheck: false, immutableCheck: { warnAfter: Number.POSITIVE_INFINITY } }), }), }; }); @@ -185,6 +188,30 @@ describe('FNDashboard', () => { expect(parent.destroy).not.toHaveBeenCalled(); }); + it('applies late preload updates only to the matching dashboard store', () => { + const props = { + name: 'dashboard', + uid: dashboardUIDs[0], + isLoading: jest.fn(), + pageTitle: 'Dashboard', + setErrors: jest.fn(), + metadata: { teams: [], eventListener: null }, + fnError: null, + preloadPanels: false, + }; + const view = render(); + act(() => mountDashboard(dashboardUIDs[1])); + const state = (uid: string) => mfeGetStoreState().fnGlobalReducer.grafanaStores[uid].getState().fnGlobalState; + const parent = screen.getByTestId('dashboard-quality-metrics'); + expect(state(dashboardUIDs[0]).preloadPanels).toBe(false); + view.rerender(); + expect(state(dashboardUIDs[0]).preloadPanels).toBe(true); + expect(state(dashboardUIDs[1]).preloadPanels).toBe(false); + view.rerender(); + expect(state(dashboardUIDs[0]).preloadPanels).toBe(false); + expect(screen.getByTestId('dashboard-quality-metrics')).toBe(parent); + }); + function makeModel(): TimeModel & { destroy: jest.Mock } { return { time: { from: 'now-30d', to: 'now' }, From 3e270ef13f730a95512cf7b866ecfa2f597cdc88 Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Fri, 25 Sep 2026 22:24:22 -0700 Subject: [PATCH 2/2] fix: address preload typing review --- public/app/core/reducers/fn-slice.ts | 2 +- .../dashboard/dashgrid/DashboardGrid.test.tsx | 15 ++++++++++----- .../dashboard/dashgrid/LazyLoader.test.tsx | 11 ++++++++++- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/public/app/core/reducers/fn-slice.ts b/public/app/core/reducers/fn-slice.ts index 2cd51f7141851..f1d8130c065fe 100644 --- a/public/app/core/reducers/fn-slice.ts +++ b/public/app/core/reducers/fn-slice.ts @@ -23,7 +23,7 @@ export interface FnState { controlsContainer: string | null; dashboardAccessMode: 'standard' | 'custom'; /** Host opt-in for eager canned-panel loading; never grants query access. */ - preloadPanels?: boolean; + readonly preloadPanels?: boolean; pageTitle: string; queryParams: AnyObject; hiddenVariables: string[]; diff --git a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx index a5f9ae9ed824d..f44bc9455bb9d 100644 --- a/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx @@ -165,11 +165,16 @@ describe('DashboardGrid', () => { }); afterAll(() => fetchSpy.mockRestore()); - it.each([ - { FNDashboard: true, dashboardAccessMode: 'standard' as const, preloadPanels: true, lazy: false }, - { FNDashboard: true, dashboardAccessMode: 'standard' as const, preloadPanels: false, lazy: true }, - { FNDashboard: true, dashboardAccessMode: 'custom' as const, preloadPanels: true, lazy: true }, - { FNDashboard: false, dashboardAccessMode: 'standard' as const, preloadPanels: true, lazy: true }, + it.each<{ + readonly FNDashboard: boolean; + readonly dashboardAccessMode: 'standard' | 'custom'; + readonly preloadPanels: boolean; + readonly lazy: boolean; + }>([ + { FNDashboard: true, dashboardAccessMode: 'standard', preloadPanels: true, lazy: false }, + { FNDashboard: true, dashboardAccessMode: 'standard', preloadPanels: false, lazy: true }, + { FNDashboard: true, dashboardAccessMode: 'custom', preloadPanels: true, lazy: true }, + { FNDashboard: false, dashboardAccessMode: 'standard', preloadPanels: true, lazy: true }, ])('only preloads explicitly opted-in embedded standard dashboards: %j', async ({ lazy, ...fnState }) => { jest.mocked(LazyLoader).mockClear(); setup( diff --git a/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx b/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx index 8d1f0e78cc079..06d809d0bcf75 100644 --- a/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx +++ b/public/app/features/dashboard/dashgrid/LazyLoader.test.tsx @@ -48,7 +48,16 @@ describe('LazyLoader preload', () => { expect(screen.queryByText('off-screen panel') !== null).toBe(preload); const element = container.firstElementChild!; const changeVisibility = (isIntersecting: boolean) => { - act(() => LazyLoader.callbacks[element.id]({ target: element, isIntersecting } as IntersectionObserverEntry)); + const entry: IntersectionObserverEntry = { + target: element, + isIntersecting, + intersectionRatio: isIntersecting ? 1 : 0, + time: 0, + boundingClientRect: element.getBoundingClientRect(), + intersectionRect: element.getBoundingClientRect(), + rootBounds: null, + }; + act(() => LazyLoader.callbacks[element.id](entry)); }; changeVisibility(true); expect(screen.getByText('visible panel')).toBeInTheDocument();