Files
twenty/packages/twenty-front/src/modules/command-menu-item/engine-command/hooks/useMountCommand.ts
T
DeviSriSaiCharan 947a4d5253 Fix: prevent unexpected navigation when destroying record from side panel (#21391)
Fixes: #21243 

# Issue
When a user is on a specific record's page (for example, looking at a
Person) and opens a related record (like a Note) in the right-side
panel, clicking "Permanently Delete" on that Note would abruptly
redirect the user to the main "Notes" list. This breaks the user's
workflow, as they typically want to remain on the parent Person page
after deleting a sub-record.

# Root Cause
Inside the `DestroyRecordsCommand` and `DeleteRecordsCommand`
components, the application was programmed to unconditionally trigger a
`navigateApp` redirect to the deleted object's index page upon
successful deletion. The code did not account for whether the deletion
was triggered from the main index page or from inside a contextual side
panel.

# How we fixed it
I introduced a new `isInSidePanel` flag into the
`HeadlessCommandContextApi`.
The command menu now detects if the delete action originated from inside
a side panel and passes this flag down the execution chain. If `true`,
the `DestroyRecordsCommand` simply closes the panel
(`closeSidePanelMenu()`) and keeps the user exactly where they were.

## After that fix, I encountered another issue (UI not updating
automatically)
Because the app now correctly kept the user on the Person page, a new
bug surfaced: the "Note" chip inside the relation table did not
disappear immediately. The user had to manually refresh the page to see
the deletion.

This happened because:
1. The Apollo optimistic cache occasionally failed to trace deeply
nested morph-relationships back to the parent.
2. A standard local React `useState` fallback was insufficient. The
table component aggressively unmounts and remounts relation cells
whenever a user hovers over them to display interactive controls, which
would wipe the local React state clean and cause the "ghost chip" to
reappear.

##How I fixed that issue (and optimized it)
I built an event-driven fallback using global Jotai state to permanently
hide the chips:

1. **Precision ID Broadcasting**: I updated the `useDestroyManyRecords`
and `useIncrementalDestroyManyRecords` hooks to extract and broadcast
only the *confirmed* destroyed IDs directly from the Apollo mutation
response, preventing false-positive UI removals if the backend performed
a partial delete.
2. **Batch Optimizations**: During bulk deletions, events are now
broadcasted per-batch rather than accumulating thousands of IDs in
memory until the end, keeping the UI instantly responsive and memory
bounded.
3. **Per-Cell State Scoping (`atomFamily`)**: Instead of a leaky global
array, we used Jotai's `atomFamily` to dynamically generate a unique
Noticeboard for every specific table cell (`${recordId}-${fieldName}`).
This ensures the hidden-state survives mouse-hover unmounts while
remaining cleanly isolated.
4. **Defensive Filtering**: The `RelationFromManyFieldDisplay` component
was updated to defensively guard against `undefined` array entries and
strictly match deleted IDs only against valid foreign keys (ending in
`'Id'`), preventing false-positive removals if a UUID happened to be
pasted into a description field.
5. **SSE Resilience**: We hardened the Server-Sent Event (SSE) listeners
with optional chaining and null-filtering to ensure malformed backend
payloads do not crash the real-time event pipeline.


# Screen Recording


https://github.com/user-attachments/assets/19fc8a3f-ba28-43c2-b1f3-a91127cdad97

---------

Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
2026-06-11 15:40:56 +00:00

86 lines
2.7 KiB
TypeScript

import { useCallback } from 'react';
import { useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation } from '@/command-menu-item/engine-command/hooks/useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation';
import { headlessCommandContextApisState } from '@/command-menu-item/engine-command/states/headlessCommandContextApisState';
import { buildHeadlessCommandContextApi } from '@/command-menu-item/engine-command/utils/buildHeadlessCommandContextApi';
import { useStore } from 'jotai';
import { isDefined } from 'twenty-shared/utils';
import {
type CommandMenuItemAvailabilityType,
type EngineComponentKey,
type CommandMenuItemPayload,
} from '~/generated-metadata/graphql';
type MountCommandParams = {
engineCommandId: string;
contextStoreInstanceId: string;
engineComponentKey: EngineComponentKey;
frontComponentId?: string;
workflowVersionId?: string;
availabilityType?: CommandMenuItemAvailabilityType;
availabilityObjectMetadataId?: string | null;
payload?: CommandMenuItemPayload | null;
isInSidePanel?: boolean;
};
export const useMountCommand = () => {
const store = useStore();
const {
enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation,
} = useEnrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation();
const mountCommand = useCallback(
async ({
engineCommandId,
contextStoreInstanceId,
engineComponentKey,
frontComponentId,
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
payload,
isInSidePanel,
}: MountCommandParams) => {
const headlessEngineCommandContextApi = buildHeadlessCommandContextApi({
store,
contextStoreInstanceId,
engineComponentKey,
payload,
isInSidePanel,
});
const commandState = isDefined(frontComponentId)
? { ...headlessEngineCommandContextApi, frontComponentId }
: isDefined(workflowVersionId) && isDefined(availabilityType)
? await enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation(
{
headlessEngineCommandContextApi,
workflowVersionId,
availabilityType,
availabilityObjectMetadataId,
},
)
: headlessEngineCommandContextApi;
if (!isDefined(commandState)) {
return;
}
store.set(headlessCommandContextApisState.atom, (previousMap) => {
const newMap = new Map(previousMap);
newMap.set(engineCommandId, commandState);
return newMap;
});
},
[
store,
enrichHeadlessCommandContextApiWithWorkflowVersionTriggerInformation,
],
);
return mountCommand;
};