diff --git a/packages/scenes-app/src/components/Routes/Routes.tsx b/packages/scenes-app/src/components/Routes/Routes.tsx index fec7205d3..b9daebafb 100644 --- a/packages/scenes-app/src/components/Routes/Routes.tsx +++ b/packages/scenes-app/src/components/Routes/Routes.tsx @@ -1,12 +1,20 @@ import * as React from 'react'; -import { Route, Routes } from 'react-router-dom'; +import { Route, Routes, useLocation } from 'react-router-dom'; import { ROUTES } from '../../constants'; import { DemoListPage } from '../../pages/DemoListPage'; import GrafanaMonitoringApp from '../../monitoring-app/GrafanaMonitoringApp'; import { ReactDemoPage } from '../../react-demo/Home'; import { HomePage } from '../../home-demo/HomeApp'; +import { SceneObjectBase } from '@grafana/scenes'; export function AppRoutes() { + const location = useLocation(); + const params = new URLSearchParams(location.search); + + if (params.get('renderBeforeActivation') === 'true') { + SceneObjectBase.RENDER_BEFORE_ACTIVATION_DEFAULT = true; + } + return ( diff --git a/packages/scenes-app/src/demos/flickeringDemo.tsx b/packages/scenes-app/src/demos/flickeringDemo.tsx new file mode 100644 index 000000000..a53fd9f11 --- /dev/null +++ b/packages/scenes-app/src/demos/flickeringDemo.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { + EmbeddedScene, + PanelBuilders, + SceneAppPage, + SceneAppPageState, + SceneCSSGridLayout, + SceneFlexItem, + SceneFlexLayout, + SceneObjectBase, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneVariableSet, + VariableValueSelectors, +} from '@grafana/scenes'; +import { getQueryRunnerWithRandomWalkQuery } from './utils'; +import { Button, InlineSwitch } from '@grafana/ui'; +import { useLocation } from 'react-router-dom'; +import { locationUtil } from '@grafana/data'; + +export function getFlickeringDemo(defaults: SceneAppPageState) { + const layout = new SceneCSSGridLayout({ + autoRows: 'auto', + children: [], + isLazy: true, + }); + + layout.setState({ + children: [ + PanelBuilders.timeseries() + .setTitle('Panel with explore button') + .setData(getQueryRunnerWithRandomWalkQuery()) + .setHeaderActions() + .build(), + PanelBuilders.timeseries().setTitle('Panel below').setData(getQueryRunnerWithRandomWalkQuery()).build(), + ], + }); + + return new SceneAppPage({ + ...defaults, + $timeRange: new SceneTimeRange(), + controls: [new RenderBeforeActivationSwitch({}), new SceneTimePicker({}), new SceneRefreshPicker({})], + tabs: [ + new SceneAppPage({ + title: 'Overview', + url: `${defaults.url}/overview`, + routePath: 'overview', + getScene: () => { + return new EmbeddedScene({ + controls: [new VariableValueSelectors({})], + $variables: new SceneVariableSet({ + variables: [], + }), + body: layout, + }); + }, + }), + new SceneAppPage({ + title: 'Details', + url: `${defaults.url}/details`, + routePath: 'details', + getScene: () => { + return new EmbeddedScene({ + body: new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexItem({ + body: PanelBuilders.timeseries() + .setTitle('Panel with explore button') + .setData(getQueryRunnerWithRandomWalkQuery()) + .build(), + }), + ], + }), + }); + }, + }), + ], + }); +} + +interface VizPanelExploreButtonProps { + layout: SceneCSSGridLayout; +} + +let counter = 0; + +function getNewPanel(layout: SceneCSSGridLayout) { + counter++; + + if (counter % 2 === 0) { + return PanelBuilders.timeseries() + .setTitle(`Another panel ${counter}`) + .setData(getQueryRunnerWithRandomWalkQuery()) + .setHeaderActions() + .build(); + } + + return PanelBuilders.gauge() + .setTitle(`Another panel ${counter}`) + .setData(getQueryRunnerWithRandomWalkQuery()) + .setHeaderActions() + .build(); +} + +function SwitchPanelButton({ layout }: VizPanelExploreButtonProps) { + const onClick = () => { + layout.setState({ children: [getNewPanel(layout), ...layout.state.children.slice(1)] }); + }; + + return ( + + ); +} + +export class RenderBeforeActivationSwitch extends SceneObjectBase { + public static Component = RenderBeforeActivationSwitchRenderer; +} + +function RenderBeforeActivationSwitchRenderer() { + const location = useLocation(); + + const onToggle = (evt: React.ChangeEvent) => { + const url = locationUtil.getUrlForPartial(location, { + renderBeforeActivation: evt.currentTarget.checked ? 'true' : null, + }); + window.location.href = locationUtil.assureBaseUrl(url); + }; + + return ( + + ); +} diff --git a/packages/scenes-app/src/demos/index.ts b/packages/scenes-app/src/demos/index.ts index 2170c9734..bb733298d 100644 --- a/packages/scenes-app/src/demos/index.ts +++ b/packages/scenes-app/src/demos/index.ts @@ -43,6 +43,7 @@ import { getSceneGraphEventsDemo } from './sceneGraphEvents'; import { getSeriesLimitTest } from './seriesLimit'; import { getScopesDemo } from './scopesDemo'; import { getVariableWithObjectValuesDemo } from './variableWithObjectValuesDemo'; +import { getFlickeringDemo } from './flickeringDemo'; export interface DemoDescriptor { title: string; @@ -319,5 +320,11 @@ export function getDemos(): DemoDescriptor[] { getPage: getVariableWithObjectValuesDemo, getSourceCodeModule: () => import('!!raw-loader!../demos/variableWithObjectValuesDemo.tsx'), }, + { + title: 'Flickering demo', + description: 'Demo showing flickering panels', + getPage: getFlickeringDemo, + getSourceCodeModule: () => import('!!raw-loader!../demos/flickeringDemo.tsx'), + }, ].sort((a, b) => a.title.localeCompare(b.title)); } diff --git a/packages/scenes-app/src/demos/scopesDemo.tsx b/packages/scenes-app/src/demos/scopesDemo.tsx index 524112fe6..bf1b81050 100644 --- a/packages/scenes-app/src/demos/scopesDemo.tsx +++ b/packages/scenes-app/src/demos/scopesDemo.tsx @@ -5,24 +5,28 @@ import { SceneAppPageState, SceneFlexItem, SceneFlexLayout, + SceneObjectBase, SceneVariableSet, ScopesVariable, + VariableValueSelectors, VizPanel, } from '@grafana/scenes'; import { EmbeddedSceneWithContext } from '@grafana/scenes-react'; import { getEmbeddedSceneDefaults, getPromQueryInstant } from './utils'; +SceneObjectBase.RENDER_BEFORE_ACTIVATION_DEFAULT = true; + export function getScopesDemo(defaults: SceneAppPageState) { return new SceneAppPage({ ...defaults, - $variables: new SceneVariableSet({ - variables: [new ScopesVariable({ enable: true }), new AdHocFiltersVariable({ layout: 'combobox' })], - }), getScene: () => { return new EmbeddedSceneWithContext({ ...getEmbeddedSceneDefaults(), - + $variables: new SceneVariableSet({ + variables: [new ScopesVariable({ enable: true }), new AdHocFiltersVariable({ layout: 'combobox' })], + }), key: 'Prometheus query that uses scopes', + controls: [new VariableValueSelectors({})], body: new SceneFlexLayout({ direction: 'column', children: [ diff --git a/packages/scenes-react/src/components/RefreshPicker.tsx b/packages/scenes-react/src/components/RefreshPicker.tsx index fad3f2274..7054a9fbc 100644 --- a/packages/scenes-react/src/components/RefreshPicker.tsx +++ b/packages/scenes-react/src/components/RefreshPicker.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useId } from 'react'; import { useSceneContext } from '../hooks/hooks'; import { SceneRefreshPicker, SceneRefreshPickerState } from '@grafana/scenes'; import { usePrevious } from 'react-use'; +import { useAddToScene } from '../contexts/SceneContextObject'; export interface Props { refresh?: string; @@ -22,7 +23,7 @@ export function RefreshPicker(props: Props) { }); } - useEffect(() => scene.addToScene(picker), [picker, scene]); + useAddToScene(picker, scene); // Update options useEffect(() => { diff --git a/packages/scenes-react/src/components/SceneFlexLayout.tsx b/packages/scenes-react/src/components/SceneFlexLayout.tsx index f78a5b3c6..e9364f4a3 100644 --- a/packages/scenes-react/src/components/SceneFlexLayout.tsx +++ b/packages/scenes-react/src/components/SceneFlexLayout.tsx @@ -9,6 +9,7 @@ import { import { useSceneContext } from '../hooks/hooks'; import { SceneFlexItem, type SceneFlexItemProps } from './SceneFlexItem'; import { SceneFlexLayoutContext } from './SceneFlexLayoutContext'; +import { useAddToScene } from '../contexts/SceneContextObject'; export interface SceneFlexLayoutProps extends SceneFlexItemPlacement { children: React.ReactNode; @@ -43,7 +44,7 @@ export function SceneFlexLayout(props: SceneFlexLayoutProps) { }); } - useEffect(() => scene.addToScene(layout), [layout, scene]); + useAddToScene(layout, scene); // Keep layout placement props in sync (but do not touch children here). useEffect(() => { diff --git a/packages/scenes-react/src/components/VizPanel.test.tsx b/packages/scenes-react/src/components/VizPanel.test.tsx index 30b308866..a29ad87fc 100644 --- a/packages/scenes-react/src/components/VizPanel.test.tsx +++ b/packages/scenes-react/src/components/VizPanel.test.tsx @@ -59,7 +59,7 @@ describe('VizPanel', () => { it('Should render with titleItems', () => { const scene = new SceneContextObject(); const viz = VizConfigBuilders.timeseries().build(); - const titleItems =
Title Item
; + const titleItems =
Title Item
; const { rerender, unmount } = render( diff --git a/packages/scenes-react/src/components/VizPanel.tsx b/packages/scenes-react/src/components/VizPanel.tsx index 21abf5462..23d455d89 100644 --- a/packages/scenes-react/src/components/VizPanel.tsx +++ b/packages/scenes-react/src/components/VizPanel.tsx @@ -13,6 +13,7 @@ import { getPanelOptionsWithDefaults } from '@grafana/data'; import { PanelContext } from '@grafana/ui'; import { writeSceneLog } from '../utils'; import { useSceneContext } from '../hooks/hooks'; +import { useAddToScene } from '../contexts/SceneContextObject'; export interface VizPanelProps { title: string; @@ -81,7 +82,7 @@ export function VizPanel(props: VizPanelProps) { }); } - useEffect(() => scene.addToScene(panel), [panel, scene]); + useAddToScene(panel, scene); // Update options useEffect(() => { diff --git a/packages/scenes-react/src/contexts/SceneContextObject.tsx b/packages/scenes-react/src/contexts/SceneContextObject.tsx index efad0a575..07c247c22 100644 --- a/packages/scenes-react/src/contexts/SceneContextObject.tsx +++ b/packages/scenes-react/src/contexts/SceneContextObject.tsx @@ -7,6 +7,7 @@ import { NewSceneObjectAddedEvent, } from '@grafana/scenes'; import { writeSceneLog } from '../utils'; +import { useEffect } from 'react'; export interface SceneContextObjectState extends SceneObjectState { childContexts?: SceneContextObject[]; @@ -87,3 +88,36 @@ export class SceneContextObject extends SceneObjectBase writeSceneLog('SceneContext', `Remvoing child context: ${ctx.constructor.name} key: ${ctx.state.key}`); } } + +export function useAddToScene(obj: SceneObject, ctx: SceneContextObject) { + // Old behavior + if (!SceneObjectBase.RENDER_BEFORE_ACTIVATION_DEFAULT) { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => ctx.addToScene(obj), [ctx, obj]); + return; + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const deactivate = obj.activate(); + + return () => { + writeSceneLog('SceneContext', `Removing from scene: ${obj.constructor.name} key: ${obj.state.key}`); + ctx.setState({ children: ctx.state.children.filter((x) => x !== obj) }); + + deactivate(); + }; + }, [ctx, obj]); + + // Check if scene contains object instance + if (ctx.state.children.includes(obj)) { + return; + } + + // This is technically a state change during render. We have to add it to the state tree right away in order to render the object on the first pass + // Should be ok as nothing subscribes to SceneContextObject state changes and the NewSceneObjectAddedEvent is syncing url state to obj state + ctx.publishEvent(new NewSceneObjectAddedEvent(obj), true); + ctx.setState({ children: [...ctx.state.children, obj] }); + + writeSceneLog('SceneContext', `Adding to scene: ${obj.constructor.name} key: ${obj.state.key}`); +} diff --git a/packages/scenes-react/src/hooks/useDataTransformer.ts b/packages/scenes-react/src/hooks/useDataTransformer.ts index 6c1ecadb0..3d367f87e 100644 --- a/packages/scenes-react/src/hooks/useDataTransformer.ts +++ b/packages/scenes-react/src/hooks/useDataTransformer.ts @@ -8,6 +8,7 @@ import { useSceneContext } from './hooks'; import { useEffect, useId } from 'react'; import { isEqual } from 'lodash'; import { DataTransformerConfig } from '@grafana/schema'; +import { useAddToScene } from '../contexts/SceneContextObject'; export interface UseDataTransformerOptions { transformations: Array; @@ -28,7 +29,7 @@ export function useDataTransformer(options: UseDataTransformerOptions) { }); } - useEffect(() => scene.addToScene(dataTransformer), [dataTransformer, scene]); + useAddToScene(dataTransformer, scene); useEffect(() => { if (!isEqual(dataTransformer.state.transformations, options.transformations)) { diff --git a/packages/scenes-react/src/hooks/useSceneObject.ts b/packages/scenes-react/src/hooks/useSceneObject.ts index f44fe0c6a..fc8e36aa5 100644 --- a/packages/scenes-react/src/hooks/useSceneObject.ts +++ b/packages/scenes-react/src/hooks/useSceneObject.ts @@ -1,7 +1,8 @@ -import { useEffect, useId } from 'react'; +import { useId } from 'react'; import { SceneObject, sceneGraph } from '@grafana/scenes'; import { useSceneContext } from './hooks'; import { CacheKey, SceneObjectConstructor, getSceneObjectCache } from '../caching/SceneObjectCache'; +import { useAddToScene } from '../contexts/SceneContextObject'; export interface UseSceneObjectProps { factory: (key: string) => T; @@ -44,7 +45,7 @@ export function useSceneObject(options: UseSceneObjectPro } } - useEffect(() => scene.addToScene(obj), [obj, scene]); + useAddToScene(obj, scene); return obj; } diff --git a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx index ac2028082..92496f4ba 100644 --- a/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx +++ b/packages/scenes/src/components/VizPanel/VizPanelRenderer.tsx @@ -1,9 +1,18 @@ import { Trans } from '@grafana/i18n'; -import React, { RefCallback, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; +import React, { memo, RefCallback, useCallback, useEffect, useLayoutEffect, useMemo } from 'react'; import { useMeasure } from 'react-use'; // @ts-ignore -import { AlertState, GrafanaTheme2, PanelData, PluginContextProvider, SetPanelAttentionEvent } from '@grafana/data'; +import { + AlertState, + GrafanaTheme2, + PanelData, + PanelPlugin, + PanelProps, + PluginContextProvider, + PluginType, + SetPanelAttentionEvent, +} from '@grafana/data'; import { getAppEvents } from '@grafana/runtime'; import { PanelChrome, ErrorBoundaryAlert, PanelContextProvider, Tooltip, useStyles2, Icon } from '@grafana/ui'; @@ -38,7 +47,7 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { collapsed, _renderCounter = 0, } = model.useState(); - const [ref, { width, height }] = useMeasure(); + let [ref, { width, height }] = useMeasure(); const appEvents = useMemo(() => getAppEvents(), []); const setPanelAttention = useCallback(() => { @@ -78,7 +87,7 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { } }); - const plugin = model.getPlugin(); + const plugin = model.getPlugin() ?? getLoadingPlugin(); const { dragClass, dragClassCancel } = getDragClasses(model); const dragHooks = getDragHooks(model); @@ -103,16 +112,6 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { const titleInterpolated = model.interpolate(title, undefined, 'text'); const alertStateStyles = useStyles2(getAlertStateStyles); - if (!plugin) { - return ( -
- - Loading plugin panel... - -
- ); - } - if (!plugin.panel) { return (
@@ -233,73 +232,75 @@ export function VizPanelRenderer({ model }: SceneComponentProps) { return (
} className={absoluteWrapper} data-viz-panel-key={model.state.key}> - {width > 0 && height > 0 && ( - 0 ? titleItemsElement : undefined} - dragClass={dragClass} - actions={actionsElement} - dragClassCancel={dragClassCancel} - padding={plugin.noPadding ? 'none' : 'md'} - menu={panelMenu} - onCancelQuery={model.onCancelQuery} - onFocus={setPanelAttention} - onMouseEnter={setPanelAttention} - onMouseMove={debouncedMouseMove} - // @ts-expect-error remove this on next grafana/ui update - subHeaderContent={subHeaderElement.length ? subHeaderElement : undefined} - onDragStart={(e: React.PointerEvent) => { - dragHooks.onDragStart?.(e, model); - }} - showMenuAlways={showMenuAlways} - {...(collapsible - ? { - collapsible: Boolean(collapsible), - collapsed, - onToggleCollapse: model.onToggleCollapse, - } - : { hoverHeader, hoverHeaderOffset })} - > - {(innerWidth, innerHeight) => ( - <> - - - - {isReadyToRender && ( - - )} - - - - - )} - - )} + 0 ? titleItemsElement : undefined} + dragClass={dragClass} + actions={actionsElement} + dragClassCancel={dragClassCancel} + padding={plugin.noPadding ? 'none' : 'md'} + menu={panelMenu} + onCancelQuery={model.onCancelQuery} + onFocus={setPanelAttention} + onMouseEnter={setPanelAttention} + onMouseMove={debouncedMouseMove} + // @ts-expect-error remove this on next grafana/ui update + subHeaderContent={subHeaderElement.length ? subHeaderElement : undefined} + onDragStart={(e: React.PointerEvent) => { + dragHooks.onDragStart?.(e, model); + }} + showMenuAlways={showMenuAlways} + {...(collapsible + ? { + collapsible: Boolean(collapsible), + collapsed, + onToggleCollapse: model.onToggleCollapse, + } + : { hoverHeader, hoverHeaderOffset })} + > + {(innerWidth, innerHeight) => { + if (innerWidth === 0 || innerHeight === 0) { + return null; + } + + return ( + + + + {isReadyToRender && ( + + )} + + + + ); + }} +
); @@ -400,3 +401,50 @@ const getAlertStateStyles = (theme: GrafanaTheme2) => { }), }; }; + +let loadingPluginInstance: PanelPlugin | null = null; + +export function getLoadingPlugin(): PanelPlugin { + if (loadingPluginInstance) { + return loadingPluginInstance; + } + + const LoadingPluginComp = memo(() => { + return ( +
+ + Loading plugin panel... + +
+ ); + }); + + LoadingPluginComp.displayName = 'LoadingPlugin'; + + loadingPluginInstance = new PanelPlugin(LoadingPluginComp); + + loadingPluginInstance.meta = { + id: 'loading-plugin', + name: 'Loading Plugin', + sort: 100, + type: PluginType.panel, + module: '', + baseUrl: '', + info: { + author: { + name: '', + }, + description: '', + links: [], + logos: { + large: '', + small: '', + }, + screenshots: [], + updated: '', + version: '', + }, + }; + + return loadingPluginInstance; +} diff --git a/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx index 30d9f1841..7b16396c2 100644 --- a/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx +++ b/packages/scenes/src/components/layout/CSSGrid/SceneCSSGridLayout.tsx @@ -79,7 +79,7 @@ function SceneCSSGridLayoutRenderer({ model }: SceneCSSGridItemRenderProps + ); diff --git a/packages/scenes/src/components/layout/LazyLoader.tsx b/packages/scenes/src/components/layout/LazyLoader.tsx index da251f6fa..4e4d1699e 100644 --- a/packages/scenes/src/components/layout/LazyLoader.tsx +++ b/packages/scenes/src/components/layout/LazyLoader.tsx @@ -4,7 +4,6 @@ import { useEffectOnce } from 'react-use'; import { uniqueId } from 'lodash'; import { css } from '@emotion/css'; import { useStyles2 } from '@grafana/ui'; -import { t } from '@grafana/i18n'; export function useUniqueId(): string { const idRefLazy = useRef(undefined); @@ -17,8 +16,19 @@ export interface Props extends Omit, 'onChange' key: string; onLoad?: () => void; onChange?: (isInView: boolean) => void; + /** + * mount (default) = mounts/renders children when they are first in view. + * query = children is always rendered. Only sets the LazyLoaderInViewContext which is used to block query execution for panels out of view + */ + mode?: LazyLoaderMode; } +/** + * mount (default) = mounts children when they are first in view. + * query = children is always rendered. Only sets the LazyLoaderInViewContext which is used to block query execution for panels out of view + */ +export type LazyLoaderMode = 'mount' | 'query'; + export interface LazyLoaderType extends ForwardRefExoticComponent { addCallback: (id: string, c: (e: IntersectionObserverEntry) => void) => void; callbacks: Record void>; @@ -26,7 +36,7 @@ export interface LazyLoaderType extends ForwardRefExoticComponent { } export const LazyLoader: LazyLoaderType = React.forwardRef( - ({ children, onLoad, onChange, className, ...rest }, ref) => { + ({ children, onLoad, onChange, className, mode = 'mount', ...rest }, ref) => { const id = useUniqueId(); const { hideEmpty } = useStyles2(getStyles); const [loaded, setLoaded] = useState(false); @@ -67,8 +77,9 @@ export const LazyLoader: LazyLoaderType = React.forwardRef