fix(ui): crash when using notes nodes or missing node/field templates (#6412)

## Summary

Notes nodes used some overly-strict redux selectors. The selectors are
now more chill. Also fixed an issue where you couldn't edit a notes node
title.

Found another class of error related to the overly strict reducers that
caused errors when loading a workflow that had missing templates. Fixed
this with fallback wrapper component, works like an error boundary when
a template isn't found.

## Related Issues / Discussions


https://discord.com/channels/1020123559063990373/1149506274971631688/1242256425527545949

## QA Instructions

- Add a notes node to a workflow. Edit the notes title.
- Load a workflow that has nodes that aren't installed. Should get a
fallback UI for each missing node.
- Load a workflow that references a node with different inputs than are
in the template - like an old version of a node. Should get a fallback
field warning for both missing templates, or missing inputs.

## Merge Plan

n/a

## Checklist

- [x] _The PR has a short but descriptive title, suitable for a
changelog_
- [ ] _Tests added / updated (if applicable)_
- [ ] _Documentation added / updated (if applicable)_
This commit is contained in:
blessedcoolant 2024-05-21 07:59:43 +05:30 committed by GitHub
commit aa0c59bb51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 191 additions and 168 deletions

View File

@ -1,7 +1,7 @@
import { Flex, Grid, GridItem } from '@invoke-ai/ui-library';
import NodeWrapper from 'features/nodes/components/flow/nodes/common/NodeWrapper';
import { useAnyOrDirectInputFieldNames } from 'features/nodes/hooks/useAnyOrDirectInputFieldNames';
import { useConnectionInputFieldNames } from 'features/nodes/hooks/useConnectionInputFieldNames';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useFieldNames } from 'features/nodes/hooks/useFieldNames';
import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames';
import { useWithFooter } from 'features/nodes/hooks/useWithFooter';
import { memo } from 'react';
@ -20,8 +20,7 @@ type Props = {
};
const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
const inputConnectionFieldNames = useConnectionInputFieldNames(nodeId);
const inputAnyOrDirectFieldNames = useAnyOrDirectInputFieldNames(nodeId);
const fieldNames = useFieldNames(nodeId);
const withFooter = useWithFooter(nodeId);
const outputFieldNames = useOutputFieldNames(nodeId);
@ -41,9 +40,11 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
>
<Flex flexDir="column" px={2} w="full" h="full">
<Grid gridTemplateColumns="1fr auto" gridAutoRows="1fr">
{inputConnectionFieldNames.map((fieldName, i) => (
{fieldNames.connectionFields.map((fieldName, i) => (
<GridItem gridColumnStart={1} gridRowStart={i + 1} key={`${nodeId}.${fieldName}.input-field`}>
<InputField nodeId={nodeId} fieldName={fieldName} />
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
</GridItem>
))}
{outputFieldNames.map((fieldName, i) => (
@ -52,8 +53,23 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => {
</GridItem>
))}
</Grid>
{inputAnyOrDirectFieldNames.map((fieldName) => (
<InputField key={`${nodeId}.${fieldName}.input-field`} nodeId={nodeId} fieldName={fieldName} />
{fieldNames.anyOrDirectFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
))}
{fieldNames.missingFields.map((fieldName) => (
<InvocationInputFieldCheck
key={`${nodeId}.${fieldName}.input-field`}
nodeId={nodeId}
fieldName={fieldName}
>
<InputField nodeId={nodeId} fieldName={fieldName} />
</InvocationInputFieldCheck>
))}
</Flex>
</Flex>

View File

@ -1,20 +0,0 @@
import { useDoesFieldExist } from 'features/nodes/hooks/useDoesFieldExist';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type Props = PropsWithChildren<{
nodeId: string;
fieldName?: string;
}>;
export const MissingFallback = memo((props: Props) => {
// We must be careful here to avoid race conditions where a deleted node is still referenced as an exposed field
const exists = useDoesFieldExist(props.nodeId, props.fieldName);
if (!exists) {
return null;
}
return props.children;
});
MissingFallback.displayName = 'MissingFallback';

View File

@ -1,16 +1,14 @@
import { Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { Flex, FormControl } from '@invoke-ai/ui-library';
import { useConnectionState } from 'features/nodes/hooks/useConnectionState';
import { useDoesInputHaveValue } from 'features/nodes/hooks/useDoesInputHaveValue';
import { useFieldInputInstance } from 'features/nodes/hooks/useFieldInputInstance';
import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate';
import type { PropsWithChildren } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import EditableFieldTitle from './EditableFieldTitle';
import FieldHandle from './FieldHandle';
import FieldLinearViewToggle from './FieldLinearViewToggle';
import InputFieldRenderer from './InputFieldRenderer';
import { InputFieldWrapper } from './InputFieldWrapper';
interface Props {
nodeId: string;
@ -18,9 +16,7 @@ interface Props {
}
const InputField = ({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const fieldTemplate = useFieldInputTemplate(nodeId, fieldName);
const fieldInstance = useFieldInputInstance(nodeId, fieldName);
const doesFieldHaveValue = useDoesInputHaveValue(nodeId, fieldName);
const [isHovered, setIsHovered] = useState(false);
@ -55,20 +51,6 @@ const InputField = ({ nodeId, fieldName }: Props) => {
setIsHovered(false);
}, []);
if (!fieldTemplate || !fieldInstance) {
return (
<InputFieldWrapper shouldDim={shouldDim}>
<FormControl alignItems="stretch" justifyContent="space-between" flexDir="column" gap={2} h="full" w="full">
<FormLabel display="flex" alignItems="center" mb={0} px={1} gap={2} h="full">
{t('nodes.unknownInput', {
name: fieldInstance?.label ?? fieldTemplate?.title ?? fieldName,
})}
</FormLabel>
</FormControl>
</InputFieldWrapper>
);
}
if (fieldTemplate.input === 'connection' || isConnected) {
return (
<InputFieldWrapper shouldDim={shouldDim}>
@ -134,27 +116,3 @@ const InputField = ({ nodeId, fieldName }: Props) => {
};
export default memo(InputField);
type InputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
const InputFieldWrapper = memo(({ shouldDim, children }: InputFieldWrapperProps) => {
return (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
w="full"
h="full"
>
{children}
</Flex>
);
});
InputFieldWrapper.displayName = 'InputFieldWrapper';

View File

@ -0,0 +1,27 @@
import { Flex } from '@invoke-ai/ui-library';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
type InputFieldWrapperProps = PropsWithChildren<{
shouldDim: boolean;
}>;
export const InputFieldWrapper = memo(({ shouldDim, children }: InputFieldWrapperProps) => {
return (
<Flex
position="relative"
minH={8}
py={0.5}
alignItems="center"
opacity={shouldDim ? 0.5 : 1}
transitionProperty="opacity"
transitionDuration="0.1s"
w="full"
h="full"
>
{children}
</Flex>
);
});
InputFieldWrapper.displayName = 'InputFieldWrapper';

View File

@ -0,0 +1,59 @@
import { Flex, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates, selectNodesSlice } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode } from 'features/nodes/store/selectors';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
}>;
export const InvocationInputFieldCheck = memo(({ nodeId, fieldName, children }: Props) => {
const { t } = useTranslation();
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const instance = node.data.inputs[fieldName];
const template = templates[node.data.type];
const fieldTemplate = template?.inputs[fieldName];
return {
name: instance?.label || fieldTemplate?.title || fieldName,
hasInstance: Boolean(instance),
hasTemplate: Boolean(fieldTemplate),
};
}),
[fieldName, nodeId, templates]
);
const { hasInstance, hasTemplate, name } = useAppSelector(selector);
if (!hasTemplate || !hasInstance) {
return (
<Flex position="relative" minH={8} py={0.5} alignItems="center" w="full" h="full">
<FormControl
isInvalid={true}
alignItems="stretch"
justifyContent="center"
flexDir="column"
gap={2}
h="full"
w="full"
>
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</Flex>
);
}
return children;
});
InvocationInputFieldCheck.displayName = 'InvocationInputFieldCheck';

View File

@ -3,7 +3,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Flex, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import NodeSelectionOverlay from 'common/components/NodeSelectionOverlay';
import { MissingFallback } from 'features/nodes/components/flow/nodes/Invocation/MissingFallback';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { workflowExposedFieldRemoved } from 'features/nodes/store/workflowSlice';
@ -102,9 +102,9 @@ const LinearViewFieldInternal = ({ nodeId, fieldName }: Props) => {
const LinearViewField = ({ nodeId, fieldName }: Props) => {
return (
<MissingFallback nodeId={nodeId} fieldName={fieldName}>
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<LinearViewFieldInternal nodeId={nodeId} fieldName={fieldName} />
</MissingFallback>
</InvocationInputFieldCheck>
);
};

View File

@ -48,7 +48,7 @@ const NotesNode = (props: NodeProps<NotesNodeData>) => {
gap={1}
>
<Flex className="nopan" w="full" h="full" flexDir="column">
<Textarea value={notes} onChange={handleChange} rows={8} resize="none" fontSize="sm" />
<Textarea className="nodrag" value={notes} onChange={handleChange} rows={8} resize="none" fontSize="sm" />
</Flex>
</Flex>
</>

View File

@ -1,7 +1,7 @@
import { Flex, FormLabel, Icon, IconButton, Spacer, Tooltip } from '@invoke-ai/ui-library';
import FieldTooltipContent from 'features/nodes/components/flow/nodes/Invocation/fields/FieldTooltipContent';
import InputFieldRenderer from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { MissingFallback } from 'features/nodes/components/flow/nodes/Invocation/MissingFallback';
import { InvocationInputFieldCheck } from 'features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck';
import { useFieldLabel } from 'features/nodes/hooks/useFieldLabel';
import { useFieldOriginalValue } from 'features/nodes/hooks/useFieldOriginalValue';
import { useFieldTemplateTitle } from 'features/nodes/hooks/useFieldTemplateTitle';
@ -53,9 +53,9 @@ const WorkflowFieldInternal = ({ nodeId, fieldName }: Props) => {
const WorkflowField = ({ nodeId, fieldName }: Props) => {
return (
<MissingFallback nodeId={nodeId} fieldName={fieldName}>
<InvocationInputFieldCheck nodeId={nodeId} fieldName={fieldName}>
<WorkflowFieldInternal nodeId={nodeId} fieldName={fieldName} />
</MissingFallback>
</InvocationInputFieldCheck>
);
};

View File

@ -1,27 +0,0 @@
import { EMPTY_ARRAY } from 'app/store/constants';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { keys, map } from 'lodash-es';
import { useMemo } from 'react';
export const useAnyOrDirectInputFieldNames = (nodeId: string): string[] => {
const template = useNodeTemplate(nodeId);
const fieldNames = useMemo(() => {
const fields = map(template.inputs).filter((field) => {
return (
(['any', 'direct'].includes(field.input) || isSingleOrCollection(field.type)) &&
keys(TEMPLATE_BUILDER_MAP).includes(field.type.name)
);
});
const _fieldNames = getSortedFilteredFieldNames(fields);
if (_fieldNames.length === 0) {
return EMPTY_ARRAY;
}
return _fieldNames;
}, [template.inputs]);
return fieldNames;
};

View File

@ -1,29 +0,0 @@
import { EMPTY_ARRAY } from 'app/store/constants';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { keys, map } from 'lodash-es';
import { useMemo } from 'react';
export const useConnectionInputFieldNames = (nodeId: string): string[] => {
const template = useNodeTemplate(nodeId);
const fieldNames = useMemo(() => {
// get the visible fields
const fields = map(template.inputs).filter(
(field) =>
(field.input === 'connection' && !isSingleOrCollection(field.type)) ||
!keys(TEMPLATE_BUILDER_MAP).includes(field.type.name)
);
const _fieldNames = getSortedFilteredFieldNames(fields);
if (_fieldNames.length === 0) {
return EMPTY_ARRAY;
}
return _fieldNames;
}, [template.inputs]);
return fieldNames;
};

View File

@ -1,20 +0,0 @@
import { useAppSelector } from 'app/store/storeHooks';
import { isInvocationNode } from 'features/nodes/types/invocation';
export const useDoesFieldExist = (nodeId: string, fieldName?: string) => {
const doesFieldExist = useAppSelector((s) => {
const node = s.nodes.present.nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node)) {
return false;
}
if (fieldName === undefined) {
return true;
}
if (!node.data.inputs[fieldName]) {
return false;
}
return true;
});
return doesFieldExist;
};

View File

@ -1,9 +1,14 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useFieldInputTemplate = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
export const useFieldInputTemplate = (nodeId: string, fieldName: string): FieldInputTemplate => {
const template = useNodeTemplate(nodeId);
const fieldTemplate = useMemo(() => template.inputs[fieldName] ?? null, [fieldName, template.inputs]);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Field template for field ${fieldName} not found`);
return _fieldTemplate;
}, [fieldName, template.inputs]);
return fieldTemplate;
};

View File

@ -0,0 +1,39 @@
import { useNodeData } from 'features/nodes/hooks/useNodeData';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { difference, filter, keys } from 'lodash-es';
import { useMemo } from 'react';
const isConnectionInputField = (field: FieldInputTemplate) => {
return (
(field.input === 'connection' && !isSingleOrCollection(field.type)) ||
!keys(TEMPLATE_BUILDER_MAP).includes(field.type.name)
);
};
const isAnyOrDirectInputField = (field: FieldInputTemplate) => {
return (
(['any', 'direct'].includes(field.input) || isSingleOrCollection(field.type)) &&
keys(TEMPLATE_BUILDER_MAP).includes(field.type.name)
);
};
export const useFieldNames = (nodeId: string) => {
const template = useNodeTemplate(nodeId);
const node = useNodeData(nodeId);
const fieldNames = useMemo(() => {
const instanceFields = keys(node.inputs);
const allTemplateFields = keys(template.inputs);
const missingFields = difference(instanceFields, allTemplateFields);
const connectionFields = filter(template.inputs, isConnectionInputField).map((f) => f.name);
const anyOrDirectFields = filter(template.inputs, isAnyOrDirectInputField).map((f) => f.name);
return {
missingFields,
connectionFields,
anyOrDirectFields,
};
}, [node.inputs, template.inputs]);
return fieldNames;
};

View File

@ -1,14 +1,14 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/nodesSlice';
import { selectNodeData } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useNodeLabel = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId)?.label ?? null;
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
return node?.data.label;
}),
[nodeId]
);

View File

@ -1,8 +1,24 @@
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates, selectNodesSlice } from 'features/nodes/store/nodesSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateTitle = (nodeId: string): string | null => {
const template = useNodeTemplate(nodeId);
const title = useMemo(() => template.title, [template.title]);
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return null;
}
const template = templates[node.data.type];
return template?.title ?? null;
}),
[nodeId, templates]
);
const title = useAppSelector(selector);
return title;
};

View File

@ -275,10 +275,9 @@ export const nodesSlice = createSlice({
const { nodeId, label } = action.payload;
const nodeIndex = state.nodes.findIndex((n) => n.id === nodeId);
const node = state.nodes?.[nodeIndex];
if (!isInvocationNode(node)) {
return;
if (isInvocationNode(node) || isNotesNode(node)) {
node.data.label = label;
}
node.data.label = label;
},
nodeNotesChanged: (state, action: PayloadAction<{ nodeId: string; notes: string }>) => {
const { nodeId, notes } = action.payload;

View File

@ -4,7 +4,7 @@ import type { InvocationNode, InvocationNodeData } from 'features/nodes/types/in
import { isInvocationNode } from 'features/nodes/types/invocation';
import { assert } from 'tsafe';
const selectInvocationNode = (nodesSlice: NodesState, nodeId: string): InvocationNode => {
export const selectInvocationNode = (nodesSlice: NodesState, nodeId: string): InvocationNode => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
assert(isInvocationNode(node), `Node ${nodeId} is not an invocation node`);
return node;

View File

@ -1 +1 @@
__version__ = "4.2.2"
__version__ = "4.2.2post1"