fix: handle missing items gracefully in hooks and components

This resolves console errors when items are deleted or during undo/redo operations.

Changes:
- Added getItemById utility function that returns null instead of throwing
- Updated all item hooks to return null when items don't exist
- Added null checks in components that use these hooks
- Components now gracefully handle missing items by returning null
- Fixed TypeScript export syntax in standaloneExports.ts
- Added MUI dependencies as externals in webpack config

This fixes the "Item with id not found" errors that were occurring when:
- Using undo/redo functionality
- Items were deleted but components were still rendering
- React was unmounting components that referenced deleted items
This commit is contained in:
stan
2025-07-03 16:04:14 +01:00
parent 25d3acaca3
commit ac41ed7768
21 changed files with 102 additions and 27 deletions

View File

@@ -20,6 +20,11 @@ export const ConnectorControls = ({ id }: Props) => {
const connector = useConnector(id);
const { updateConnector, deleteConnector } = useScene();
// If connector doesn't exist, return null
if (!connector) {
return null;
}
return (
<ControlsContainer>
<Section>

View File

@@ -35,12 +35,17 @@ export const NodeControls = ({ id }: Props) => {
const viewItem = useViewItem(id);
const modelItem = useModelItem(id);
const { iconCategories } = useIconCategories();
const { icon } = useIcon(modelItem.icon);
const { icon } = useIcon(modelItem?.icon || '');
const onSwitchMode = useCallback((newMode: Mode) => {
setMode(newMode);
}, []);
// If items don't exist, return null (component will unmount)
if (!viewItem || !modelItem) {
return null;
}
return (
<ControlsContainer>
<Box

View File

@@ -26,6 +26,10 @@ export const NodeSettings = ({
}: Props) => {
const modelItem = useModelItem(node.id);
if (!modelItem) {
return null;
}
return (
<>
<Section title="Name">

View File

@@ -19,6 +19,11 @@ export const RectangleControls = ({ id }: Props) => {
const rectangle = useRectangle(id);
const { updateRectangle, deleteRectangle } = useScene();
// If rectangle doesn't exist, return null
if (!rectangle) {
return null;
}
return (
<ControlsContainer>
<Section>

View File

@@ -27,6 +27,11 @@ export const TextBoxControls = ({ id }: Props) => {
const textBox = useTextBox(id);
const { updateTextBox, deleteTextBox } = useScene();
// If textBox doesn't exist, return null
if (!textBox) {
return null;
}
return (
<ControlsContainer>
<Section>

View File

@@ -23,6 +23,11 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
const color = useColor(_connector.color);
const { currentView } = useScene();
const connector = useConnector(_connector.id);
if (!connector || !color) {
return null;
}
const { css, pxSize } = useIsoProjection({
...connector.path.rectangle
});

View File

@@ -19,7 +19,7 @@ interface Props {
export const Node = ({ node, order }: Props) => {
const modelItem = useModelItem(node.id);
const { iconComponent } = useIcon(modelItem.icon);
const { iconComponent } = useIcon(modelItem?.icon);
const position = useMemo(() => {
return getTilePosition({
@@ -30,13 +30,19 @@ export const Node = ({ node, order }: Props) => {
const description = useMemo(() => {
if (
!modelItem ||
modelItem.description === undefined ||
modelItem.description === MARKDOWN_EMPTY_VALUE
)
return null;
return modelItem.description;
}, [modelItem.description]);
}, [modelItem?.description]);
// If modelItem doesn't exist, don't render the node
if (!modelItem) {
return null;
}
return (
<Box
@@ -52,7 +58,7 @@ export const Node = ({ node, order }: Props) => {
top: position.y
}}
>
{(modelItem.name || description) && (
{(modelItem?.name || description) && (
<Box
sx={{ position: 'absolute' }}
style={{ bottom: PROJECTED_TILE_SIZE.height / 2 }}

View File

@@ -9,6 +9,10 @@ type Props = ReturnType<typeof useScene>['rectangles'][0];
export const Rectangle = ({ from, to, color: colorId }: Props) => {
const color = useColor(colorId);
if (!color) {
return null;
}
return (
<IsoTileArea
from={from}

View File

@@ -9,5 +9,9 @@ interface Props {
export const NodeTransformControls = ({ id }: Props) => {
const node = useViewItem(id);
if (!node) {
return null;
}
return <TransformControls from={node.tile} to={node.tile} />;
};

View File

@@ -16,6 +16,7 @@ export const RectangleTransformControls = ({ id }: Props) => {
const onAnchorMouseDown = useCallback(
(key: AnchorPosition) => {
if (!rectangle) return;
uiStateActions.setMode({
type: 'RECTANGLE.TRANSFORM',
id: rectangle.id,
@@ -23,9 +24,13 @@ export const RectangleTransformControls = ({ id }: Props) => {
showCursor: true
});
},
[rectangle.id, uiStateActions]
[rectangle?.id, uiStateActions]
);
if (!rectangle) {
return null;
}
return (
<TransformControls
from={rectangle.from}

View File

@@ -11,8 +11,13 @@ export const TextBoxTransformControls = ({ id }: Props) => {
const textBox = useTextBox(id);
const to = useMemo(() => {
if (!textBox) return { x: 0, y: 0 };
return getTextBoxEndTile(textBox, textBox.size);
}, [textBox]);
if (!textBox) {
return null;
}
return <TransformControls from={textBox.tile} to={to} />;
};

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useColor = (colorId?: string) => {
@@ -7,14 +7,11 @@ export const useColor = (colorId?: string) => {
const color = useMemo(() => {
if (colorId === undefined) {
if (colors.length > 0) {
return colors[0];
}
throw new Error('No colors available.');
return colors.length > 0 ? colors[0] : null;
}
return getItemByIdOrThrow(colors, colorId).value;
const item = getItemById(colors, colorId);
return item ? item.value : null;
}, [colorId, colors]);
return color;

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useConnector = (id: string) => {
const { connectors } = useScene();
const connector = useMemo(() => {
return getItemByIdOrThrow(connectors, id).value;
const item = getItemById(connectors, id);
return item ? item.value : null;
}, [connectors, id]);
return connector;

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useEffect } from 'react';
import { useModelStore } from 'src/stores/modelStore';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { IsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon';
import { NonIsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon';
import { DEFAULT_ICON } from 'src/config';
@@ -14,7 +14,8 @@ export const useIcon = (id: string | undefined) => {
const icon = useMemo(() => {
if (!id) return DEFAULT_ICON;
return getItemByIdOrThrow(icons, id).value;
const item = getItemById(icons, id);
return item ? item.value : DEFAULT_ICON;
}, [icons, id]);
useEffect(() => {

View File

@@ -1,15 +1,16 @@
import { useMemo } from 'react';
import { ModelItem } from 'src/types';
import { useModelStore } from 'src/stores/modelStore';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
export const useModelItem = (id: string): ModelItem => {
export const useModelItem = (id: string): ModelItem | null => {
const model = useModelStore((state) => {
return state;
});
const modelItem = useMemo(() => {
return getItemByIdOrThrow(model.items, id).value;
const item = getItemById(model.items, id);
return item ? item.value : null;
}, [id, model.items]);
return modelItem;

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useRectangle = (id: string) => {
const { rectangles } = useScene();
const rectangle = useMemo(() => {
return getItemByIdOrThrow(rectangles, id).value;
const item = getItemById(rectangles, id);
return item ? item.value : null;
}, [rectangles, id]);
return rectangle;

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useTextBox = (id: string) => {
const { textBoxes } = useScene();
const textBox = useMemo(() => {
return getItemByIdOrThrow(textBoxes, id).value;
const item = getItemById(textBoxes, id);
return item ? item.value : null;
}, [textBoxes, id]);
return textBox;

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { getItemById } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useViewItem = (id: string) => {
const { items } = useScene();
const viewItem = useMemo(() => {
return getItemByIdOrThrow(items, id).value;
const item = getItemById(items, id);
return item ? item.value : null;
}, [items, id]);
return viewItem;

View File

@@ -5,4 +5,4 @@ export * as reducers from 'src/stores/reducers';
export { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config';
export * from 'src/schemas';
export type { IsoflowProps, InitialData } from 'src/types';
export type * from 'src/types/model';
export * from 'src/types/model';

View File

@@ -94,6 +94,21 @@ export function getItemByIdOrThrow<T extends { id: string }>(
return { value: values[index], index };
}
export function getItemById<T extends { id: string }>(
values: T[],
id: string
): { value: T; index: number } | null {
const index = values.findIndex((val) => {
return val.id === id;
});
if (index === -1) {
return null;
}
return { value: values[index], index };
}
export function getItemByIndexOrThrow<T>(items: T[], index: number): T {
const item = items[index];

View File

@@ -26,7 +26,11 @@ module.exports = {
commonjs2: 'react-dom',
amd: 'ReactDOM',
root: 'ReactDOM'
}
},
'@mui/material': '@mui/material',
'@mui/icons-material': '@mui/icons-material',
'@emotion/react': '@emotion/react',
'@emotion/styled': '@emotion/styled'
},
module: {
rules: [