fix(ui): prevent entity not found errors

The canvas react components pass canvas entity identifiers around, then redux selectors are used to access that entity. This is good for perf - entity states may rapidly change. Passing only the identifiers allows components and other logic to have more granular state updates.

Unfortunately, this design opens the possibility for for an entity identifier to point to an entity that does not exist.

To get around this, I had created a redux selector `selectEntityOrThrow` for canvas entities. As the name implies, it throws if the entity is not found.

While it prevents components/hooks from needing to deal with missing entities, it results in mysterious errors if an entity is missing. Without sourcemaps, it's very difficult to determine what component or hook couldn't find the entity.

Refactoring the app to not depend on this behaviour is tricky. We could pass the entity state around directly as a prop or via context, but as mentioned, this could cause performance issues with rapidly changing entities.

As a workaround, I've made two changes:
- `<CanvasEntityStateGate/>` is a component that takes an entity identifier, returning its children if the entity state exists, or null if not. This component is wraps every usage of `selectEntityOrThrow`.  Theoretically, this should prevent the entity not found errors.
- Add a `caller: string` arg to `selectEntityOrThrow`. This string is now added to the error message when the assertion fails, so we can more easily track the source of the errors.

In the future we can work out a way to not use this throwing selector and retain perf. The app has changed quite a bit since that selector was created - so we may not have to worry about perf at all.
This commit is contained in:
psychedelicious
2024-11-18 13:04:45 -06:00
parent 2da25a0043
commit 0bb601aaf7
20 changed files with 229 additions and 136 deletions

View File

@@ -3,6 +3,7 @@ import { Alert, AlertIcon, AlertTitle } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle';
import { useEntityTypeIsHidden } from 'features/controlLayers/hooks/useEntityTypeIsHidden';
@@ -29,17 +30,23 @@ type AlertData = {
title: string;
};
const buildSelectIsEnabled = (entityIdentifier: CanvasEntityIdentifier) =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'CanvasAlertsSelectedEntityStatusContent').isEnabled
);
const buildSelectIsLocked = (entityIdentifier: CanvasEntityIdentifier) =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'CanvasAlertsSelectedEntityStatusContent').isLocked
);
const CanvasAlertsSelectedEntityStatusContent = memo(({ entityIdentifier, adapter }: ContentProps) => {
const { t } = useTranslation();
const title = useEntityTitle(entityIdentifier);
const selectIsEnabled = useMemo(
() => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).isEnabled),
[entityIdentifier]
);
const selectIsLocked = useMemo(
() => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).isLocked),
[entityIdentifier]
);
const selectIsEnabled = useMemo(() => buildSelectIsEnabled(entityIdentifier), [entityIdentifier]);
const selectIsLocked = useMemo(() => buildSelectIsLocked(entityIdentifier), [entityIdentifier]);
const isEnabled = useAppSelector(selectIsEnabled);
const isLocked = useAppSelector(selectIsLocked);
const isHidden = useEntityTypeIsHidden(entityIdentifier.type);
@@ -115,7 +122,11 @@ export const CanvasAlertsSelectedEntityStatus = memo(() => {
return null;
}
return <CanvasAlertsSelectedEntityStatusContent entityIdentifier={selectedEntityIdentifier} adapter={adapter} />;
return (
<CanvasEntityStateGate entityIdentifier={selectedEntityIdentifier}>
<CanvasAlertsSelectedEntityStatusContent entityIdentifier={selectedEntityIdentifier} adapter={adapter} />
</CanvasEntityStateGate>
);
});
CanvasAlertsSelectedEntityStatus.displayName = 'CanvasAlertsSelectedEntityStatus';

View File

@@ -5,6 +5,7 @@ import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintM
import { IPAdapterMenuItems } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItems';
import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems';
import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import {
EntityIdentifierContext,
useEntityIdentifierContext,
@@ -40,6 +41,15 @@ const CanvasContextMenuSelectedEntityMenuItemsContent = memo(() => {
CanvasContextMenuSelectedEntityMenuItemsContent.displayName = 'CanvasContextMenuSelectedEntityMenuItemsContent';
const CanvasContextMenuSelectedEntityMenuGroup = memo((props: PropsWithChildren) => {
const entityIdentifier = useEntityIdentifierContext();
const title = useEntityTypeString(entityIdentifier.type);
return <MenuGroup title={title}>{props.children}</MenuGroup>;
});
CanvasContextMenuSelectedEntityMenuGroup.displayName = 'CanvasContextMenuSelectedEntityMenuGroup';
export const CanvasContextMenuSelectedEntityMenuItems = memo(() => {
const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier);
@@ -49,20 +59,13 @@ export const CanvasContextMenuSelectedEntityMenuItems = memo(() => {
return (
<EntityIdentifierContext.Provider value={selectedEntityIdentifier}>
<CanvasContextMenuSelectedEntityMenuGroup>
<CanvasContextMenuSelectedEntityMenuItemsContent />
</CanvasContextMenuSelectedEntityMenuGroup>
<CanvasEntityStateGate entityIdentifier={selectedEntityIdentifier}>
<CanvasContextMenuSelectedEntityMenuGroup>
<CanvasContextMenuSelectedEntityMenuItemsContent />
</CanvasContextMenuSelectedEntityMenuGroup>
</CanvasEntityStateGate>
</EntityIdentifierContext.Provider>
);
});
CanvasContextMenuSelectedEntityMenuItems.displayName = 'CanvasContextMenuSelectedEntityMenuItems';
const CanvasContextMenuSelectedEntityMenuGroup = memo((props: PropsWithChildren) => {
const entityIdentifier = useEntityIdentifierContext();
const title = useEntityTypeString(entityIdentifier.type);
return <MenuGroup title={title}>{props.children}</MenuGroup>;
});
CanvasContextMenuSelectedEntityMenuGroup.displayName = 'CanvasContextMenuSelectedEntityMenuGroup';

View File

@@ -7,6 +7,7 @@ import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/c
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { ControlLayerBadges } from 'features/controlLayers/components/ControlLayer/ControlLayerBadges';
import { ControlLayerSettings } from 'features/controlLayers/components/ControlLayer/ControlLayerSettings';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
@@ -36,24 +37,26 @@ export const ControlLayer = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<ControlLayerAdapterGate>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<ControlLayerBadges />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<CanvasEntitySettingsWrapper>
<ControlLayerSettings />
</CanvasEntitySettingsWrapper>
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.replaceLayer')}
isDisabled={isBusy}
/>
</CanvasEntityContainer>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<ControlLayerBadges />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<CanvasEntitySettingsWrapper>
<ControlLayerSettings />
</CanvasEntitySettingsWrapper>
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.replaceLayer')}
isDisabled={isBusy}
/>
</CanvasEntityContainer>
</CanvasEntityStateGate>
</ControlLayerAdapterGate>
</EntityIdentifierContext.Provider>
);

View File

@@ -1,26 +1,47 @@
import { Badge } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import { memo } from 'react';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const ControlLayerBadges = memo(() => {
const entityIdentifier = useEntityIdentifierContext('control_layer');
const { t } = useTranslation();
const withTransparencyEffect = useAppSelector(
(s) => selectEntityOrThrow(selectCanvasSlice(s), entityIdentifier).withTransparencyEffect
const buildSelectWithTransparencyEffect = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerBadgesContent').withTransparencyEffect
);
const ControlLayerBadgesContent = memo(() => {
const entityIdentifier = useEntityIdentifierContext('control_layer');
const { t } = useTranslation();
const selectWithTransparencyEffect = useMemo(
() => buildSelectWithTransparencyEffect(entityIdentifier),
[entityIdentifier]
);
const withTransparencyEffect = useAppSelector(selectWithTransparencyEffect);
if (!withTransparencyEffect) {
return null;
}
return (
<>
{withTransparencyEffect && (
<Badge color="base.300" bg="transparent" borderWidth={1} userSelect="none">
{t('controlLayers.transparency')}
</Badge>
)}
</>
<Badge color="base.300" bg="transparent" borderWidth={1} userSelect="none">
{t('controlLayers.transparency')}
</Badge>
);
});
ControlLayerBadgesContent.displayName = 'ControlLayerBadgesContent';
export const ControlLayerBadges = memo(() => {
const entityIdentifier = useEntityIdentifierContext('control_layer');
return (
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<ControlLayerBadgesContent />
</CanvasEntityStateGate>
);
});
ControlLayerBadges.displayName = 'ControlLayerBadges';

View File

@@ -28,24 +28,18 @@ import { useTranslation } from 'react-i18next';
import { PiBoundingBoxBold, PiShootingStarFill, PiUploadBold } from 'react-icons/pi';
import type { ControlNetModelConfig, ImageDTO, T2IAdapterModelConfig } from 'services/api/types';
const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) => {
const selectControlAdapter = useMemo(
() =>
createMemoizedAppSelector(selectCanvasSlice, (canvas) => {
const layer = selectEntityOrThrow(canvas, entityIdentifier);
return layer.controlAdapter;
}),
[entityIdentifier]
);
const controlAdapter = useAppSelector(selectControlAdapter);
return controlAdapter;
};
const buildSelectControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
createMemoizedAppSelector(selectCanvasSlice, (canvas) => {
const layer = selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerControlAdapter');
return layer.controlAdapter;
});
export const ControlLayerControlAdapter = memo(() => {
const { t } = useTranslation();
const { dispatch, getState } = useAppStore();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const controlAdapter = useControlLayerControlAdapter(entityIdentifier);
const selectControlAdapter = useMemo(() => buildSelectControlAdapter(entityIdentifier), [entityIdentifier]);
const controlAdapter = useAppSelector(selectControlAdapter);
const filter = useEntityFilter(entityIdentifier);
const isFLUX = useAppSelector(selectIsFLUX);
const adapter = useEntityAdapterContext('control_layer');

View File

@@ -5,21 +5,25 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti
import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked';
import { controlLayerWithTransparencyEffectToggled } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDropHalfBold } from 'react-icons/pi';
const buildSelectWithTransparencyEffect = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) =>
createSelector(
selectCanvasSlice,
(canvas) =>
selectEntityOrThrow(canvas, entityIdentifier, 'ControlLayerMenuItemsTransparencyEffect').withTransparencyEffect
);
export const ControlLayerMenuItemsTransparencyEffect = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('control_layer');
const isLocked = useEntityIsLocked(entityIdentifier);
const selectWithTransparencyEffect = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntityOrThrow(canvas, entityIdentifier);
return entity.withTransparencyEffect;
}),
() => buildSelectWithTransparencyEffect(entityIdentifier),
[entityIdentifier]
);
const withTransparencyEffect = useAppSelector(selectWithTransparencyEffect);

View File

@@ -4,6 +4,7 @@ import { CanvasEntityHeader } from 'features/controlLayers/components/common/Can
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
@@ -17,14 +18,16 @@ export const IPAdapter = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader ps={4} py={5}>
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<IPAdapterSettings />
</CanvasEntityContainer>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader ps={4} py={5}>
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<IPAdapterSettings />
</CanvasEntityContainer>
</CanvasEntityStateGate>
</EntityIdentifierContext.Provider>
);
});

View File

@@ -18,7 +18,7 @@ import {
} from 'features/controlLayers/store/canvasSlice';
import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import type { CanvasEntityIdentifier, CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types';
import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd';
import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd';
import { memo, useCallback, useMemo } from 'react';
@@ -29,14 +29,17 @@ import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types';
import { IPAdapterImagePreview } from './IPAdapterImagePreview';
import { IPAdapterModel } from './IPAdapterModel';
const buildSelectIPAdapter = (entityIdentifier: CanvasEntityIdentifier<'reference_image'>) =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'IPAdapterSettings').ipAdapter
);
export const IPAdapterSettings = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext('reference_image');
const selectIPAdapter = useMemo(
() => createSelector(selectCanvasSlice, (s) => selectEntityOrThrow(s, entityIdentifier).ipAdapter),
[entityIdentifier]
);
const selectIPAdapter = useMemo(() => buildSelectIPAdapter(entityIdentifier), [entityIdentifier]);
const ipAdapter = useAppSelector(selectIPAdapter);
const onChangeBeginEndStepPct = useCallback(

View File

@@ -4,6 +4,7 @@ import { CanvasEntityHeader } from 'features/controlLayers/components/common/Can
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { InpaintMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
@@ -19,14 +20,16 @@ export const InpaintMask = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<InpaintMaskAdapterGate>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
</CanvasEntityContainer>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
</CanvasEntityContainer>
</CanvasEntityStateGate>
</InpaintMaskAdapterGate>
</EntityIdentifierContext.Provider>
);

View File

@@ -4,6 +4,7 @@ import { CanvasEntityHeader } from 'features/controlLayers/components/common/Can
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage';
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
@@ -30,20 +31,22 @@ export const RasterLayer = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<RasterLayerAdapterGate>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.replaceLayer')}
isDisabled={isBusy}
/>
</CanvasEntityContainer>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
label={t('controlLayers.replaceLayer')}
isDisabled={isBusy}
/>
</CanvasEntityContainer>
</CanvasEntityStateGate>
</RasterLayerAdapterGate>
</EntityIdentifierContext.Provider>
);

View File

@@ -6,6 +6,7 @@ import { CanvasEntityPreviewImage } from 'features/controlLayers/components/comm
import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit';
import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges';
import { RegionalGuidanceSettings } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceSettings';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { RegionalGuidanceAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
@@ -24,16 +25,18 @@ export const RegionalGuidance = memo(({ id }: Props) => {
return (
<EntityIdentifierContext.Provider value={entityIdentifier}>
<RegionalGuidanceAdapterGate>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<RegionalGuidanceBadges />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RegionalGuidanceSettings />
</CanvasEntityContainer>
<CanvasEntityStateGate entityIdentifier={entityIdentifier}>
<CanvasEntityContainer>
<CanvasEntityHeader>
<CanvasEntityPreviewImage />
<CanvasEntityEditableTitle />
<Spacer />
<RegionalGuidanceBadges />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RegionalGuidanceSettings />
</CanvasEntityContainer>
</CanvasEntityStateGate>
</RegionalGuidanceAdapterGate>
</EntityIdentifierContext.Provider>
);

View File

@@ -10,7 +10,11 @@ export const RegionalGuidanceBadges = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const { t } = useTranslation();
const selectAutoNegative = useMemo(
() => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).autoNegative),
() =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'RegionalGuidanceBadges').autoNegative
),
[entityIdentifier]
);
const autoNegative = useAppSelector(selectAutoNegative);

View File

@@ -13,7 +13,11 @@ export const RegionalGuidanceIPAdapters = memo(() => {
const selectIPAdapterIds = useMemo(
() =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
const ipAdapterIds = selectEntityOrThrow(canvas, entityIdentifier).referenceImages.map(({ id }) => id);
const ipAdapterIds = selectEntityOrThrow(
canvas,
entityIdentifier,
'RegionalGuidanceIPAdapters'
).referenceImages.map(({ id }) => id);
if (ipAdapterIds.length === 0) {
return EMPTY_ARRAY;
}

View File

@@ -13,7 +13,11 @@ export const RegionalGuidanceMenuItemsAutoNegative = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const selectAutoNegative = useMemo(
() => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).autoNegative),
() =>
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'RegionalGuidanceMenuItemsAutoNegative').autoNegative
),
[entityIdentifier]
);
const autoNegative = useAppSelector(selectAutoNegative);

View File

@@ -20,7 +20,10 @@ export const RegionalGuidanceNegativePrompt = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectPrompt = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).negativePrompt ?? ''),
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'RegionalGuidanceNegativePrompt').negativePrompt ?? ''
),
[entityIdentifier]
);
const prompt = useAppSelector(selectPrompt);

View File

@@ -20,7 +20,10 @@ export const RegionalGuidancePositivePrompt = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectPrompt = useMemo(
() =>
createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).positivePrompt ?? ''),
createSelector(
selectCanvasSlice,
(canvas) => selectEntityOrThrow(canvas, entityIdentifier, 'RegionalGuidancePositivePrompt').positivePrompt ?? ''
),
[entityIdentifier]
);
const prompt = useAppSelector(selectPrompt);

View File

@@ -5,26 +5,25 @@ import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/c
import { RegionalGuidanceAddPromptsIPAdapterButtons } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import { memo, useMemo } from 'react';
import { RegionalGuidanceIPAdapters } from './RegionalGuidanceIPAdapters';
import { RegionalGuidanceNegativePrompt } from './RegionalGuidanceNegativePrompt';
import { RegionalGuidancePositivePrompt } from './RegionalGuidancePositivePrompt';
const buildSelectFlags = (entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>) =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntityOrThrow(canvas, entityIdentifier, 'RegionalGuidanceSettings');
return {
hasPositivePrompt: entity.positivePrompt !== null,
hasNegativePrompt: entity.negativePrompt !== null,
hasIPAdapters: entity.referenceImages.length > 0,
};
});
export const RegionalGuidanceSettings = memo(() => {
const entityIdentifier = useEntityIdentifierContext('regional_guidance');
const selectFlags = useMemo(
() =>
createMemoizedSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntityOrThrow(canvas, entityIdentifier);
return {
hasPositivePrompt: entity.positivePrompt !== null,
hasNegativePrompt: entity.negativePrompt !== null,
hasIPAdapters: entity.referenceImages.length > 0,
};
}),
[entityIdentifier]
);
const selectFlags = useMemo(() => buildSelectFlags(entityIdentifier), [entityIdentifier]);
const flags = useAppSelector(selectFlags);
return (

View File

@@ -0,0 +1,17 @@
import { useAppSelector } from 'app/store/storeHooks';
import { selectEntityExists } from 'features/controlLayers/store/selectors';
import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types';
import type { PropsWithChildren } from 'react';
import { memo, useMemo } from 'react';
export const CanvasEntityStateGate = memo((props: PropsWithChildren<{ entityIdentifier: CanvasEntityIdentifier }>) => {
const selector = useMemo(() => selectEntityExists(props.entityIdentifier), [props.entityIdentifier]);
const entityExistsInState = useAppSelector(selector);
if (!entityExistsInState) {
return null;
}
return props.children;
});
CanvasEntityStateGate.displayName = 'CanvasEntityStateGate';

View File

@@ -14,7 +14,7 @@ import {
rgPositivePromptChanged,
} from 'features/controlLayers/store/canvasSlice';
import { selectBase } from 'features/controlLayers/store/paramsSlice';
import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type {
CanvasEntityIdentifier,
CanvasRegionalGuidanceState,
@@ -168,7 +168,7 @@ export const buildSelectValidRegionalGuidanceActions = (
entityIdentifier: CanvasEntityIdentifier<'regional_guidance'>
) => {
return createMemoizedSelector(selectCanvasSlice, (canvas) => {
const entity = selectEntityOrThrow(canvas, entityIdentifier);
const entity = selectEntity(canvas, entityIdentifier);
return {
canAddPositivePrompt: entity?.positivePrompt === null,
canAddNegativePrompt: entity?.negativePrompt === null,

View File

@@ -202,17 +202,25 @@ export const selectRegionalGuidanceEntities = createSelector(
/**
* Selected an entity from the canvas slice. If the entity is not found, an error is thrown.
*
* Provide a `caller` string to help identify the caller in the error message.
*
* Wrapper around {@link selectEntity}.
*/
export function selectEntityOrThrow<T extends CanvasEntityIdentifier>(
state: CanvasState,
entityIdentifier: T
entityIdentifier: T,
caller: string
): Extract<CanvasEntityState, T> {
const entity = selectEntity(state, entityIdentifier);
assert(entity, `Entity with id ${entityIdentifier.id} not found`);
assert(entity, `Entity with id ${entityIdentifier.id} not found in ${caller}`);
return entity;
}
export const selectEntityExists = <T extends CanvasEntityIdentifier>(entityIdentifier: T) => {
return createSelector(selectCanvasSlice, (canvas) => Boolean(selectEntity(canvas, entityIdentifier)));
};
/**
* Selects all entities of the given type.
*/