From bf4310ca711b2eed400fa712e06a024010f139e6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 21 May 2024 11:22:08 +1000 Subject: [PATCH] fix(ui): errors when node template or field template doesn't exist Some asserts were bubbling up in places where they shouldn't have, causing errors when a node has a field without a matching template, or vice-versa. To resolve this without sacrificing the runtime safety provided by asserts, a `InvocationFieldCheck` component now wraps all field components. This component renders a fallback when a field doesn't exist, so the inner components can safely use the asserts. --- .../flow/nodes/Invocation/InvocationNode.tsx | 32 +++++++--- .../flow/nodes/Invocation/MissingFallback.tsx | 20 ------- .../nodes/Invocation/fields/InputField.tsx | 46 +-------------- .../Invocation/fields/InputFieldWrapper.tsx | 27 +++++++++ .../fields/InvocationFieldCheck.tsx | 59 +++++++++++++++++++ .../Invocation/fields/LinearViewField.tsx | 6 +- .../sidePanel/viewMode/WorkflowField.tsx | 6 +- .../hooks/useAnyOrDirectInputFieldNames.ts | 27 --------- .../hooks/useConnectionInputFieldNames.ts | 29 --------- .../features/nodes/hooks/useDoesFieldExist.ts | 20 ------- .../nodes/hooks/useFieldInputTemplate.ts | 9 ++- .../src/features/nodes/hooks/useFieldNames.ts | 39 ++++++++++++ .../web/src/features/nodes/store/selectors.ts | 2 +- 13 files changed, 165 insertions(+), 157 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/MissingFallback.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useAnyOrDirectInputFieldNames.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useConnectionInputFieldNames.ts delete mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useDoesFieldExist.ts create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useFieldNames.ts diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx index 0147bcaed2..baa7fc262a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx @@ -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) => { > - {inputConnectionFieldNames.map((fieldName, i) => ( + {fieldNames.connectionFields.map((fieldName, i) => ( - + + + ))} {outputFieldNames.map((fieldName, i) => ( @@ -52,8 +53,23 @@ const InvocationNode = ({ nodeId, isOpen, label, type, selected }: Props) => { ))} - {inputAnyOrDirectFieldNames.map((fieldName) => ( - + {fieldNames.anyOrDirectFields.map((fieldName) => ( + + + + ))} + {fieldNames.missingFields.map((fieldName) => ( + + + ))} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/MissingFallback.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/MissingFallback.tsx deleted file mode 100644 index ca5b74b7ff..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/MissingFallback.tsx +++ /dev/null @@ -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'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx index 474e0651f7..fd3cc6d6bf 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx @@ -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 ( - - - - {t('nodes.unknownInput', { - name: fieldInstance?.label ?? fieldTemplate?.title ?? fieldName, - })} - - - - ); - } - if (fieldTemplate.input === 'connection' || isConnected) { return ( @@ -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 ( - - {children} - - ); -}); - -InputFieldWrapper.displayName = 'InputFieldWrapper'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper.tsx new file mode 100644 index 0000000000..8723538f85 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper.tsx @@ -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 ( + + {children} + + ); +}); + +InputFieldWrapper.displayName = 'InputFieldWrapper'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx new file mode 100644 index 0000000000..f4b6be0cd6 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InvocationFieldCheck.tsx @@ -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 ( + + + + {t('nodes.unknownInput', { name })} + + + + ); + } + + return children; +}); + +InvocationInputFieldCheck.displayName = 'InvocationInputFieldCheck'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx index f7ff85f479..ef466b2882 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/LinearViewField.tsx @@ -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 ( - + - + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx index a30bda354d..482de6693e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/WorkflowField.tsx @@ -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 ( - + - + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useAnyOrDirectInputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useAnyOrDirectInputFieldNames.ts deleted file mode 100644 index 7fae0de16e..0000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useAnyOrDirectInputFieldNames.ts +++ /dev/null @@ -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; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useConnectionInputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useConnectionInputFieldNames.ts deleted file mode 100644 index 16ace597c1..0000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useConnectionInputFieldNames.ts +++ /dev/null @@ -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; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useDoesFieldExist.ts b/invokeai/frontend/web/src/features/nodes/hooks/useDoesFieldExist.ts deleted file mode 100644 index 4e97b1689c..0000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useDoesFieldExist.ts +++ /dev/null @@ -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; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useFieldInputTemplate.ts index 4b70847ad1..729319e0dd 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useFieldInputTemplate.ts @@ -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; }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useFieldNames.ts new file mode 100644 index 0000000000..19849fb296 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useFieldNames.ts @@ -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; +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/selectors.ts b/invokeai/frontend/web/src/features/nodes/store/selectors.ts index 4739a77e1c..be8cfafa8b 100644 --- a/invokeai/frontend/web/src/features/nodes/store/selectors.ts +++ b/invokeai/frontend/web/src/features/nodes/store/selectors.ts @@ -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;