perf(ui): optimize workflow editor inspector panel rendering

This commit is contained in:
psychedelicious
2025-02-16 10:29:03 +10:00
parent b50241fe6a
commit 726b4637db
7 changed files with 110 additions and 97 deletions

View File

@@ -5,18 +5,20 @@ import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataView
import { selectLastSelectedNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDebounce } from 'use-debounce';
const selector = createSelector(selectNodesSlice, (nodes) => selectLastSelectedNode(nodes)?.data);
const InspectorDataTab = () => {
const { t } = useTranslation();
const lastSelectedNodeData = useAppSelector(selector);
const [debouncedLastSelectedNodeData] = useDebounce(lastSelectedNodeData, 300);
if (!lastSelectedNodeData) {
if (!debouncedLastSelectedNodeData) {
return <IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />;
}
return <DataViewer data={lastSelectedNodeData} label="Node Data" />;
return <DataViewer data={debouncedLastSelectedNodeData} label="Node Data" />;
};
export default memo(InspectorDataTab);

View File

@@ -1,82 +1,64 @@
import { Box, Flex, FormControl, FormLabel, HStack, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea';
import { useNodeIsInvocationNode } from 'features/nodes/hooks/useNodeIsInvocationNode';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectLastSelectedNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import EditableNodeTitle from './details/EditableNodeTitle';
const InspectorDetailsTab = () => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodes) => {
const lastSelectedNode = selectLastSelectedNode(nodes);
const lastSelectedNodeTemplate = lastSelectedNode ? templates[lastSelectedNode.data.type] : undefined;
if (!isInvocationNode(lastSelectedNode) || !lastSelectedNodeTemplate) {
return;
}
return {
nodeId: lastSelectedNode.data.id,
nodeVersion: lastSelectedNode.data.version,
templateTitle: lastSelectedNodeTemplate.title,
};
}),
[templates]
);
const data = useAppSelector(selector);
const { t } = useTranslation();
const lastSelectedNodeId = useAppSelector(selectLastSelectedNodeId);
if (!data) {
if (!lastSelectedNodeId) {
return <IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />;
}
return <Content nodeId={data.nodeId} nodeVersion={data.nodeVersion} templateTitle={data.templateTitle} />;
return (
<TemplateGate
nodeId={lastSelectedNodeId}
fallback={<IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />}
>
<Content nodeId={lastSelectedNodeId} />
</TemplateGate>
);
};
export default memo(InspectorDetailsTab);
type ContentProps = {
nodeId: string;
nodeVersion: string;
templateTitle: string;
};
const Content = memo((props: ContentProps) => {
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const needsUpdate = useNodeNeedsUpdate(props.nodeId);
const isInvocationNode = useNodeIsInvocationNode(props.nodeId);
const version = useNodeVersion(nodeId);
const template = useNodeTemplate(nodeId);
const needsUpdate = useNodeNeedsUpdate(nodeId);
return (
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex flexDir="column" position="relative" w="full" h="full" p={1} gap={2}>
<EditableNodeTitle nodeId={props.nodeId} />
<EditableNodeTitle nodeId={nodeId} />
<HStack>
<FormControl>
<FormLabel>{t('nodes.nodeType')}</FormLabel>
<Text fontSize="sm" fontWeight="semibold">
{props.templateTitle}
{template.title}
</Text>
</FormControl>
<FormControl isInvalid={needsUpdate}>
<FormLabel>{t('nodes.nodeVersion')}</FormLabel>
<Text fontSize="sm" fontWeight="semibold">
{props.nodeVersion}
{version}
</Text>
</FormControl>
</HStack>
{isInvocationNode && <InvocationNodeNotesTextarea nodeId={props.nodeId} />}
<InvocationNodeNotesTextarea nodeId={nodeId} />
</Flex>
</ScrollableContent>
</Box>

View File

@@ -1,48 +1,46 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectLastSelectedNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { memo, useMemo } from 'react';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AnyInvocationOutput, ImageOutput } from 'services/api/types';
import ImageOutputPreview from './outputs/ImageOutputPreview';
const InspectorOutputsTab = () => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodes) => {
const lastSelectedNode = selectLastSelectedNode(nodes);
const lastSelectedNodeTemplate = lastSelectedNode ? templates[lastSelectedNode.data.type] : undefined;
if (!isInvocationNode(lastSelectedNode) || !lastSelectedNodeTemplate) {
return;
}
return {
nodeId: lastSelectedNode.id,
outputType: lastSelectedNodeTemplate.outputType,
};
}),
[templates]
);
const data = useAppSelector(selector);
const nes = useNodeExecutionState(data?.nodeId);
const lastSelectedNodeId = useAppSelector(selectLastSelectedNodeId);
const { t } = useTranslation();
if (!data || !nes) {
if (!lastSelectedNodeId) {
return <IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />;
}
if (nes.outputs.length === 0) {
return (
<TemplateGate
nodeId={lastSelectedNodeId}
fallback={<IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />}
>
<Content nodeId={lastSelectedNodeId} />
</TemplateGate>
);
};
export default memo(InspectorOutputsTab);
const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`;
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplate(nodeId);
const nes = useNodeExecutionState(nodeId);
if (!nes || nes.outputs.length === 0) {
return <IAINoContentFallback label={t('nodes.noOutputRecorded')} icon={null} />;
}
@@ -50,7 +48,7 @@ const InspectorOutputsTab = () => {
<Box position="relative" w="full" h="full">
<ScrollableContent>
<Flex position="relative" flexDir="column" alignItems="flex-start" p={1} gap={2} h="full" w="full">
{data.outputType === 'image_output' ? (
{template.outputType === 'image_output' ? (
nes.outputs.map((result, i) => (
<ImageOutputPreview key={getKey(result, i)} output={result as ImageOutput} />
))
@@ -61,8 +59,5 @@ const InspectorOutputsTab = () => {
</ScrollableContent>
</Box>
);
};
export default memo(InspectorOutputsTab);
const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`;
});
Content.displayName = 'Content';

View File

@@ -1,33 +1,36 @@
import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectLastSelectedNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { memo, useMemo } from 'react';
import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { selectLastSelectedNodeId } from 'features/nodes/store/selectors';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const NodeTemplateInspector = () => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createMemoizedSelector(selectNodesSlice, (nodes) => {
const lastSelectedNode = selectLastSelectedNode(nodes);
const lastSelectedNodeTemplate = lastSelectedNode ? templates[lastSelectedNode.data.type] : undefined;
return lastSelectedNodeTemplate;
}),
[templates]
);
const template = useAppSelector(selector);
const lastSelectedNodeId = useAppSelector(selectLastSelectedNodeId);
const { t } = useTranslation();
if (!template) {
if (!lastSelectedNodeId) {
return <IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />;
}
return <DataViewer data={template} label={t('nodes.nodeTemplate')} />;
return (
<TemplateGate
nodeId={lastSelectedNodeId}
fallback={<IAINoContentFallback label={t('nodes.noNodeSelected')} icon={null} />}
>
<Content nodeId={lastSelectedNodeId} />
</TemplateGate>
);
};
export default memo(NodeTemplateInspector);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplate(nodeId);
return <DataViewer data={template} label={t('nodes.nodeTemplate')} />;
});
Content.displayName = 'Content';

View File

@@ -0,0 +1,16 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplate';
import type { PropsWithChildren, ReactNode } from 'react';
import { memo } from 'react';
export const TemplateGate = memo(
({ nodeId, fallback, children }: PropsWithChildren<{ nodeId: string; fallback: ReactNode }>) => {
const template = useNodeTemplateSafe(nodeId);
if (!template) {
return fallback;
}
return <>{children}</>;
}
);
TemplateGate.displayName = 'TemplateGate';

View File

@@ -15,3 +15,10 @@ export const useNodeTemplate = (nodeId: string): InvocationTemplate => {
}, [templates, type]);
return template;
};
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
};

View File

@@ -56,6 +56,14 @@ export const selectLastSelectedNode = (nodesSlice: NodesState) => {
export const selectNodesSlice = (state: RootState) => state.nodes.present;
export const selectLastSelectedNodeId = createSelector(selectNodesSlice, ({ nodes }) => {
const selectedNodes = nodes.filter(isInvocationNode).filter((n) => n.selected);
if (selectedNodes.length === 1) {
return selectedNodes[0]?.id;
}
return null;
});
const createNodesSelector = <T>(selector: Selector<NodesState, T>) => createSelector(selectNodesSlice, selector);
export const selectNodes = createNodesSelector((nodes) => nodes.nodes);
export const selectEdges = createNodesSelector((nodes) => nodes.edges);