Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 575e83c292 Widget type select crashes when editing widget ID is cleared
https://sonarly.com/issue/29531?type=bug

SidePanelPageLayoutRecordPageWidgetTypeSelect crashes during render when both `pageLayoutEditingWidgetId` and `widgetCreationTargetTabId` are null. This occurs because the Replace Widget and Add Widget Above/Below navigation paths never set `widgetCreationTargetTabId` as a fallback, and `pageLayoutEditingWidgetId` can be independently cleared by auth-triggered re-renders or edit mode toggling.

Fix: Changed `resolveWidgetTypeSelectTargetTabId` to return `null` instead of throwing when it encounters invalid state (both `pageLayoutEditingWidgetId` and `widgetCreationTargetTabId` are null, or the editing widget isn't found in any tab). This converts an unrecoverable render-time crash into a recoverable state.

In `SidePanelPageLayoutRecordPageWidgetTypeSelect`:
1. Added `useEffect` that calls `closeSidePanelMenu()` when `tabId` is null — this gracefully closes the side panel when state becomes inconsistent (e.g. after auth token renewal resets `pageLayoutEditingWidgetId`).
2. Added early `return null` after all hooks when `tabId` is null, preventing the component from rendering with invalid state.
3. Added `!isDefined(tabId)` guards at the top of all 4 `useCallback` handlers (`removeExistingWidgetIfReplacing`, `handleCreateFieldsWidget`, `handleCreateFieldWidget`, `handleCreateFrontComponentWidget`) to ensure TypeScript type safety and prevent operations with a null tab ID.

Updated the existing test file to expect `null` returns instead of thrown errors for the two edge cases.

The fix does not change behavior for any valid navigation path — `resolveWidgetTypeSelectTargetTabId` still returns the correct tab ID when state is properly set. It only adds graceful degradation for the race condition where external state resets (auth renewal, edit mode toggling) clear `pageLayoutEditingWidgetId` while the widget type select page is still on the side panel navigation stack.
2026-04-21 16:25:38 +00:00
3 changed files with 47 additions and 27 deletions
@@ -28,7 +28,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconApps, IconList } from 'twenty-ui/display';
@@ -115,7 +115,11 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
}, [existingWidget]);
const removeExistingWidgetIfReplacing = useCallback(() => {
if (!isReplaceMode || !isDefined(pageLayoutEditingWidgetId)) {
if (
!isReplaceMode ||
!isDefined(pageLayoutEditingWidgetId) ||
!isDefined(tabId)
) {
return;
}
@@ -145,6 +149,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
);
const handleCreateFieldsWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
const viewId = uuidv4();
@@ -191,6 +199,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
]);
const handleCreateFieldWidget = useCallback(() => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -264,6 +276,10 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
const handleCreateFrontComponentWidget = useCallback(
(frontComponent: FrontComponent) => {
if (!isDefined(tabId)) {
return;
}
const replacePositionIndex = getExistingWidgetPositionIndex();
removeExistingWidgetIfReplacing();
@@ -326,6 +342,16 @@ export const SidePanelPageLayoutRecordPageWidgetTypeSelect = () => {
],
);
useEffect(() => {
if (!isDefined(tabId)) {
closeSidePanelMenu();
}
}, [tabId, closeSidePanelMenu]);
if (!isDefined(tabId)) {
return null;
}
const selectableItemIds = [
'fields',
'field',
@@ -33,16 +33,16 @@ describe('resolveWidgetTypeSelectTargetTabId', () => {
expect(result).toBe('tab-2');
});
it('should throw when the editing widget is not found in any tab', () => {
it('should return null when the editing widget is not found in any tab', () => {
const tabs = [makeTab('tab-1', ['widget-a'])];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'non-existent',
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow('Cannot find tab containing editing widget non-existent');
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: 'non-existent',
tabs,
widgetCreationTargetTabId: null,
});
expect(result).toBeNull();
});
it('should return widgetCreationTargetTabId when no editing widget is set', () => {
@@ -57,17 +57,15 @@ describe('resolveWidgetTypeSelectTargetTabId', () => {
expect(result).toBe('tab-1');
});
it('should throw when both pageLayoutEditingWidgetId and widgetCreationTargetTabId are null', () => {
it('should return null when both pageLayoutEditingWidgetId and widgetCreationTargetTabId are null', () => {
const tabs = [makeTab('tab-1')];
expect(() =>
resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: null,
}),
).toThrow(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
const result = resolveWidgetTypeSelectTargetTabId({
pageLayoutEditingWidgetId: null,
tabs,
widgetCreationTargetTabId: null,
});
expect(result).toBeNull();
});
});
@@ -9,25 +9,21 @@ export const resolveWidgetTypeSelectTargetTabId = ({
pageLayoutEditingWidgetId: string | null;
tabs: PageLayoutTab[];
widgetCreationTargetTabId: string | null;
}): string => {
}): string | null => {
if (isDefined(pageLayoutEditingWidgetId)) {
const editingWidgetTab = tabs.find((tab) =>
tab.widgets.some((widget) => widget.id === pageLayoutEditingWidgetId),
);
if (!isDefined(editingWidgetTab)) {
throw new Error(
`Cannot find tab containing editing widget ${pageLayoutEditingWidgetId}`,
);
return null;
}
return editingWidgetTab.id;
}
if (!isDefined(widgetCreationTargetTabId)) {
throw new Error(
'widgetCreationTargetTabId must be set when navigating to widget type select without an editing widget',
);
return null;
}
return widgetCreationTargetTabId;