diff --git a/.changeset/legal-impalas-shine.md b/.changeset/legal-impalas-shine.md new file mode 100644 index 0000000000..f9d54690dc --- /dev/null +++ b/.changeset/legal-impalas-shine.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-visualizer': patch +--- + +Improved rendering performance of the details page. diff --git a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx index 1d38ec8267..259ee143c9 100644 --- a/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx +++ b/plugins/app-visualizer/src/components/AppVisualizerPage/DetailedVisualizer.tsx @@ -31,6 +31,7 @@ import { RiCloseCircleLine as DisabledIcon, } from '@remixicon/react'; import { Focusable } from 'react-aria-components'; +import { memo, useMemo, useState, useEffect, useRef, Fragment } from 'react'; function getContrastColor(bgColor: string): string { const hex = bgColor.replace('#', ''); @@ -105,131 +106,149 @@ function getFullPath(node?: AppNode): string { return getFullPath(parent) + part; } -function Output(props: { dataRef: ExtensionDataRef; node?: AppNode }) { - const { dataRef, node } = props; - const { id } = dataRef; - const instance = node?.instance; - - const routeResolutionApi = useApi(routeResolutionApiRef); - - const { backgroundColor, color } = getOutputColor(id); - - const chipStyle: React.CSSProperties = { - height: 20, - padding: '0 10px', - borderRadius: '10px', - color, - backgroundColor, - display: 'flex', - alignItems: 'center', - fontWeight: - 'var(--bui-font-weight-regular)' as React.CSSProperties['fontWeight'], - }; - - if (id === coreExtensionData.routeRef.id && node) { - try { - const routeRef = props.node?.instance?.getData( - coreExtensionData.routeRef, - ); - const link = routeRef && routeResolutionApi.resolve(routeRef)?.(); - if (link) { - return ( - - - link - - {id} - - ); - } - } catch { - /* ignore */ - } - } - - let tooltip = id; - let text: string | undefined = undefined; - if (id === coreExtensionData.routePath.id) { - text = String(instance?.getData(dataRef) ?? ''); - tooltip = getFullPath(node); - } - - return ( - - - {text} - - {tooltip} - - ); -} - -function Attachments(props: { - node: AppNode; - enabled: boolean; - depth: number; +function ProgressiveCollection({ + items, + batchSize = 10, +}: { + items: Array<() => React.ReactElement>; + batchSize?: number; }) { - const { node, depth } = props; - const { attachments } = node.edges; + const [maxIndex, setMaxIndex] = useState(0); + const timeoutRef = useRef | null>(null); - if (attachments.size === 0) { - return null; - } + useEffect(() => { + if (maxIndex >= items.length) { + return undefined; + } + + const processBatch = () => { + const nextIndex = Math.min(maxIndex + batchSize, items.length); + setMaxIndex(nextIndex); + + if (nextIndex < items.length) { + timeoutRef.current = setTimeout(processBatch, 0); + } + }; + + timeoutRef.current = setTimeout(processBatch, 0); + + // eslint-disable-next-line consistent-return + return () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }; + }, [maxIndex, items.length, batchSize]); return ( - - {[...attachments.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, children], idx) => { - return ( - - - -
{key}
-
- - {children.map(childNode => ( - - ))} - -
- ); - })} -
+ <> + {items.slice(0, maxIndex).map((item, index) => ( + {item()} + ))} + ); } -function Extension(props: { node: AppNode; depth: number }) { - const { node, depth } = props; +const Output = memo( + function Output(props: { + dataRef: ExtensionDataRef; + node?: AppNode; + }) { + const { dataRef, node } = props; + const { id } = dataRef; + const instance = node?.instance; + const routeResolutionApi = useApi(routeResolutionApiRef); + + const { backgroundColor, color } = getOutputColor(id); + + const chipStyle: React.CSSProperties = { + height: 20, + padding: '0 10px', + borderRadius: '10px', + color, + backgroundColor, + display: 'flex', + alignItems: 'center', + fontWeight: + 'var(--bui-font-weight-regular)' as React.CSSProperties['fontWeight'], + }; + + if (id === coreExtensionData.routeRef.id && node) { + try { + const routeRef = props.node?.instance?.getData( + coreExtensionData.routeRef, + ); + const link = routeRef && routeResolutionApi.resolve(routeRef)?.(); + if (link) { + return ( + + + link + + {id} + + ); + } + } catch { + /* ignore */ + } + } + + let tooltip = id; + let text: string | undefined = undefined; + if (id === coreExtensionData.routePath.id) { + text = String(instance?.getData(dataRef) ?? ''); + tooltip = getFullPath(node); + } + + return ( + + + {text} + + {tooltip} + + ); + }, + (prevProps, nextProps) => { + return ( + prevProps.dataRef.id === nextProps.dataRef.id && + prevProps.node?.spec.id === nextProps.node?.spec.id + ); + }, +); + +function Extension({ node, depth }: { node: AppNode; depth: number }) { const enabled = Boolean(node.instance); - const dataRefs = node.instance && [...node.instance.getDataRefs()]; - // Build tooltip text - const tooltipParts = []; - let currentNode = node; - tooltipParts.push(currentNode.spec.id); - while (currentNode.edges.attachedTo) { - const input = currentNode.edges.attachedTo.input; - currentNode = currentNode.edges.attachedTo.node; - tooltipParts.push(`${currentNode.spec.id} [${input}]`); - } - tooltipParts.reverse(); - const tooltipText = tooltipParts.join('\n'); + const tooltipText = useMemo(() => { + const tooltipParts = []; + let currentNode = node; + tooltipParts.push(currentNode.spec.id); + while (currentNode.edges.attachedTo) { + const input = currentNode.edges.attachedTo.input; + currentNode = currentNode.edges.attachedTo.node; + tooltipParts.push(`${currentNode.spec.id} [${input}]`); + } + tooltipParts.reverse(); + return tooltipParts.join('\n'); + }, [node]); + + const sortedDataRefs = useMemo(() => { + if (!node.instance) { + return []; + } + const dataRefs = [...node.instance.getDataRefs()]; + return dataRefs.sort((a, b) => a.id.localeCompare(b.id)); + }, [node.instance]); + + const sortedAttachments = useMemo(() => { + return [...node.edges.attachments.entries()].sort(([a], [b]) => + a.localeCompare(b), + ); + }, [node.edges.attachments]); return ( - {dataRefs && - dataRefs.length > 0 && - dataRefs - .sort((a, b) => a.id.localeCompare(b.id)) - .map(ref => )} + {sortedDataRefs.length > 0 && + sortedDataRefs.map(ref => ( + + ))} {!enabled && } - + {sortedAttachments.length > 0 && ( + + {sortedAttachments.map(([key, children], idx) => ( + + + +
{key}
+
+ + () => ( + + ))} + /> + +
+ ))} +
+ )}
); }