app-visualizer: improve rendering performance of details page

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-23 11:26:59 +01:00
parent 24eb7d7933
commit 4d50e1f051
2 changed files with 255 additions and 108 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-visualizer': patch
---
Improved rendering performance of the details page.
@@ -31,6 +31,7 @@ import {
RiCloseCircleLine as DisabledIcon,
} from '@remixicon/react';
import { Focusable } from 'react-aria-components';
import { memo, useMemo, useState, useEffect, useRef } from 'react';
function getContrastColor(bgColor: string): string {
const hex = bgColor.replace('#', '');
@@ -105,131 +106,233 @@ function getFullPath(node?: AppNode): string {
return getFullPath(parent) + part;
}
function Output(props: { dataRef: ExtensionDataRef<unknown>; 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 (
<TooltipTrigger>
<Link href={link} style={chipStyle}>
link
</Link>
<Tooltip>{id}</Tooltip>
</TooltipTrigger>
);
}
} catch {
/* ignore */
function collectAllNodes(node: AppNode): AppNode[] {
const nodes: AppNode[] = [node];
for (const children of node.edges.attachments.values()) {
for (const child of children) {
nodes.push(...collectAllNodes(child));
}
}
let tooltip = id;
let text: string | undefined = undefined;
if (id === coreExtensionData.routePath.id) {
text = String(instance?.getData(dataRef) ?? '');
tooltip = getFullPath(node);
}
return (
<TooltipTrigger>
<Focusable>
<Text style={{ ...chipStyle, cursor: 'help' }}>{text}</Text>
</Focusable>
<Tooltip style={{ maxWidth: 'unset' }}>{tooltip}</Tooltip>
</TooltipTrigger>
);
return nodes;
}
function Attachments(props: {
function useProgressiveRender(rootNode: AppNode) {
const [renderedNodes, setRenderedNodes] = useState<Set<string>>(new Set());
const [isComplete, setIsComplete] = useState(false);
const processingRef = useRef(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (processingRef.current) {
return undefined;
}
processingRef.current = true;
const allNodes = collectAllNodes(rootNode);
const batchSize = 10;
let currentIndex = 0;
const rendered = new Set<string>();
const processBatch = () => {
const endIndex = Math.min(currentIndex + batchSize, allNodes.length);
for (let i = currentIndex; i < endIndex; i++) {
rendered.add(allNodes[i].spec.id);
}
currentIndex = endIndex;
setRenderedNodes(new Set(rendered));
if (currentIndex < allNodes.length) {
timeoutRef.current = setTimeout(processBatch, 0);
} else {
setIsComplete(true);
processingRef.current = false;
timeoutRef.current = null;
}
};
rendered.add(rootNode.spec.id);
setRenderedNodes(new Set(rendered));
currentIndex = 1;
if (allNodes.length > 1) {
timeoutRef.current = setTimeout(processBatch, 0);
} else {
setIsComplete(true);
processingRef.current = false;
}
return () => {
processingRef.current = false;
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [rootNode]);
return { renderedNodes, isComplete };
}
const Output = memo(
function Output(props: {
dataRef: ExtensionDataRef<unknown>;
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 (
<TooltipTrigger>
<Link href={link} style={chipStyle}>
link
</Link>
<Tooltip>{id}</Tooltip>
</TooltipTrigger>
);
}
} catch {
/* ignore */
}
}
let tooltip = id;
let text: string | undefined = undefined;
if (id === coreExtensionData.routePath.id) {
text = String(instance?.getData(dataRef) ?? '');
tooltip = getFullPath(node);
}
return (
<TooltipTrigger>
<Focusable>
<Text style={{ ...chipStyle, cursor: 'help' }}>{text}</Text>
</Focusable>
<Tooltip style={{ maxWidth: 'unset' }}>{tooltip}</Tooltip>
</TooltipTrigger>
);
},
(prevProps, nextProps) => {
return (
prevProps.dataRef.id === nextProps.dataRef.id &&
prevProps.node?.spec.id === nextProps.node?.spec.id
);
},
);
function AttachmentsComponent(props: {
node: AppNode;
enabled: boolean;
depth: number;
renderedNodes: Set<string>;
}) {
const { node, depth } = props;
const { node, depth, renderedNodes } = props;
const { attachments } = node.edges;
const sortedAttachments = useMemo(() => {
return [...attachments.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [attachments]);
if (attachments.size === 0) {
return null;
}
return (
<Flex direction="column" gap="4">
{[...attachments.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, children], idx) => {
return (
<Box key={key}>
<Flex
p="2"
align="center"
style={{
borderTopWidth: 'var(--bui-space-1_5)',
borderTopStyle: 'solid',
borderTopColor: getBorderColor(depth),
borderTop: idx === 0 ? 'none' : undefined,
width: 'fit-content',
}}
>
<InputIcon size={16} />
<div style={{ marginLeft: 'var(--bui-space-2)' }}>{key}</div>
</Flex>
<Flex ml="2" mb="2" direction="column" align="start" gap="1">
{children.map(childNode => (
<Extension
key={childNode.spec.id}
node={childNode}
depth={depth + 1}
/>
))}
</Flex>
</Box>
);
})}
{sortedAttachments.map(([key, children], idx) => {
return (
<Box key={key}>
<Flex
p="2"
align="center"
style={{
borderTopWidth: 'var(--bui-space-1_5)',
borderTopStyle: 'solid',
borderTopColor: getBorderColor(depth),
borderTop: idx === 0 ? 'none' : undefined,
width: 'fit-content',
}}
>
<InputIcon size={16} />
<div style={{ marginLeft: 'var(--bui-space-2)' }}>{key}</div>
</Flex>
<Flex ml="2" mb="2" direction="column" align="start" gap="1">
{children.map(childNode => (
<ExtensionComponent
key={childNode.spec.id}
node={childNode}
depth={depth + 1}
renderedNodes={renderedNodes}
/>
))}
</Flex>
</Box>
);
})}
</Flex>
);
}
function Extension(props: { node: AppNode; depth: number }) {
const { node, depth } = props;
function ExtensionComponent(props: {
node: AppNode;
depth: number;
renderedNodes: Set<string>;
}) {
const { node, depth, renderedNodes } = props;
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}]`);
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 shouldRender = renderedNodes.has(node.spec.id);
if (!shouldRender) {
return null;
}
tooltipParts.reverse();
const tooltipText = tooltipParts.join('\n');
return (
<Box
@@ -261,19 +364,56 @@ function Extension(props: { node: AppNode; depth: number }) {
</Tooltip>
</TooltipTrigger>
<Flex ml="2" align="center" gap="2">
{dataRefs &&
dataRefs.length > 0 &&
dataRefs
.sort((a, b) => a.id.localeCompare(b.id))
.map(ref => <Output key={ref.id} dataRef={ref} node={node} />)}
{sortedDataRefs.length > 0 &&
sortedDataRefs.map(ref => (
<Output key={ref.id} dataRef={ref} node={node} />
))}
{!enabled && <DisabledIcon size={16} />}
</Flex>
</Flex>
<Attachments node={node} enabled={enabled} depth={depth} />
<AttachmentsComponent
node={node}
enabled={enabled}
depth={depth}
renderedNodes={renderedNodes}
/>
</Box>
);
}
const Extension = memo(ExtensionComponent, (prevProps, nextProps) => {
if (
prevProps.node.spec.id !== nextProps.node.spec.id ||
prevProps.depth !== nextProps.depth
) {
return false;
}
const nodeId = prevProps.node.spec.id;
const wasRendered = prevProps.renderedNodes.has(nodeId);
const isRendered = nextProps.renderedNodes.has(nodeId);
if (wasRendered !== isRendered) {
return false;
}
if (prevProps.renderedNodes.size !== nextProps.renderedNodes.size) {
return false;
}
for (const children of prevProps.node.edges.attachments.values()) {
for (const child of children) {
const wasChildRendered = prevProps.renderedNodes.has(child.spec.id);
const isChildRendered = nextProps.renderedNodes.has(child.spec.id);
if (wasChildRendered !== isChildRendered) {
return false;
}
}
}
return true;
});
const legendMap = {
'React Element': coreExtensionData.reactElement,
'Utility API': ApiBlueprint.dataRefs.factory,
@@ -305,10 +445,12 @@ function Legend() {
}
export function DetailedVisualizer({ tree }: { tree: AppTree }) {
const { renderedNodes } = useProgressiveRender(tree.root);
return (
<Flex direction="column" style={{ height: '100%', flex: '1 1 100%' }}>
<Box ml="4" mt="4" style={{ flex: '1 1 0', overflow: 'auto' }}>
<Extension node={tree.root} depth={0} />
<Extension node={tree.root} depth={0} renderedNodes={renderedNodes} />
</Box>
<Box