Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/web/core/components/issues/peek-overview/issue-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ import { IssueTitleInput } from "../title-input";
// services init
const workItemVersionService = new WorkItemVersionService();

interface IPeekOverviewIssueDetails {
type Props = {
editorRef: React.RefObject<EditorRefApi>;
workspaceSlug: string;
projectId: string;
issueId: string;
Expand All @@ -38,12 +39,11 @@ interface IPeekOverviewIssueDetails {
isArchived: boolean;
isSubmitting: TNameDescriptionLoader;
setIsSubmitting: (value: TNameDescriptionLoader) => void;
}
};

export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = observer((props) => {
const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
export const PeekOverviewIssueDetails: FC<Props> = observer((props) => {
const { editorRef, workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } =
props;
// store hooks
const { data: currentUser } = useUser();
const {
Expand Down
11 changes: 8 additions & 3 deletions apps/web/core/components/issues/peek-overview/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FC, useRef, useState } from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { EIssueServiceType, TNameDescriptionLoader } from "@plane/types";
import { cn } from "@plane/utils";
// hooks
Expand Down Expand Up @@ -53,6 +54,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const [isEditIssueModalOpen, setIsEditIssueModalOpen] = useState(false);
// ref
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const {
setPeekIssue,
Expand Down Expand Up @@ -80,8 +82,9 @@ export const IssueView: FC<IIssueView> = observer((props) => {
usePeekOverviewOutsideClickDetector(
issuePeekOverviewRef,
() => {
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (!embedIssue) {
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen) {
if (!isAnyModalOpen && !isAnyEpicModalOpen && !isAnyLocalModalOpen && !isAnyDropbarOpen) {
removeRoutePeekId();
}
}
Expand All @@ -90,10 +93,10 @@ export const IssueView: FC<IIssueView> = observer((props) => {
);

const handleKeyDown = () => {
const slashCommandDropdownElement = document.querySelector("#slash-command");
const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
const dropdownElement = document.activeElement?.tagName === "INPUT";
if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement && !editorImageFullScreenModalElement) {
const isAnyDropbarOpen = editorRef.current?.isAnyDropbarOpen();
if (!isAnyModalOpen && !dropdownElement && !isAnyDropbarOpen && !editorImageFullScreenModalElement) {
removeRoutePeekId();
const issueElement = document.getElementById(`issue-${issueId}`);
if (issueElement) issueElement?.focus();
Expand Down Expand Up @@ -166,6 +169,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
{["side-peek", "modal"].includes(peekMode) ? (
<div className="relative flex flex-col gap-3 px-8 py-5 space-y-3">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
Expand Down Expand Up @@ -206,6 +210,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<div className="relative h-full w-full space-y-6 overflow-auto p-4 py-5">
<div className="space-y-3">
<PeekOverviewIssueDetails
editorRef={editorRef}
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
Expand Down
15 changes: 10 additions & 5 deletions packages/editor/src/core/extensions/slash-commands/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,14 @@ const Command = Extension.create<SlashCommandOptions>({
},
});

const renderItems = () => {
const renderItems: SuggestionOptions["render"] = () => {
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
let popup: Instance | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
onStart: (props) => {
// Track active dropdown
props.editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);

component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
props,
editor: props.editor,
Expand All @@ -78,14 +81,14 @@ const renderItems = () => {
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
onUpdate: (props) => {
component?.updateProps(props);

popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
onKeyDown: (props) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
Expand All @@ -95,7 +98,9 @@ const renderItems = () => {
}
return false;
},
onExit: () => {
onExit: ({ editor }) => {
// Remove from active dropdowns
editor?.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.SLASH_COMMANDS);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: TipTap Suggestion Plugin Callback Parameter Issue

The onExit callback for the TipTap Suggestion plugin now destructures { editor }, but the callback typically receives no parameters. This makes editor undefined, preventing removeActiveDropbarExtension from running and leaving the slash command in the active dropdowns list, which breaks dropdown tracking.

Fix in Cursor Fix in Web

popup?.[0].destroy();
component?.destroy();
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { Ellipsis } from "lucide-react";
import { useCallback, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import {
findTable,
Expand Down Expand Up @@ -59,7 +61,16 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
}),
],
open: isDropdownOpen,
onOpenChange: setIsDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
if (open) {
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
} else {
setTimeout(() => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
}, 0);
}
},
Comment on lines +64 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Fix race when switching between table dropbars (shared key + deferred removal).

Problem:

  • Using a single key (CORE_EXTENSIONS.TABLE) and removing it via setTimeout can clear the active state when rapidly switching from one table dropdown to another (e.g., column -> row). The “close” of A schedules a removal that runs after B’s “open” added the same key, resulting in a false “no dropbar open”.

Recommendations:

  • Track per-instance IDs (ref-count) in the utility extension. Extend commands to accept an instanceId and remove only that instance.
  • Prefer queueMicrotask over setTimeout to reduce ordering surprises.
  • Ensure cleanup on unmount to avoid stale state.

Proposed change in this component (requires extending commands to accept instanceId):

-    onOpenChange: (open) => {
-      setIsDropdownOpen(open);
-      if (open) {
-        editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
-      } else {
-        setTimeout(() => {
-          editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
-        }, 0);
-      }
-    },
+    onOpenChange: (open) => {
+      setIsDropdownOpen(open);
+      if (open) {
+        editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current);
+      } else {
+        queueMicrotask(() => {
+          editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current);
+        });
+      }
+    },

Additionally (outside this hunk), create a stable instance id and clean up on unmount:

// imports: add useRef, useEffect
const instanceIdRef = useRef(Symbol("table-col-dropbar"));

useEffect(() => {
  return () => {
    // defensive cleanup
    try {
      editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current);
    } catch {}
  };
}, [editor]);

Run to verify whether ref-counting already exists (if so, just pass instance ids everywhere):

Expected:

  • Storage holds a map/set of instance IDs per extension (not a single array of extension keys).
  • Commands support an instance identifier for add/remove.
    If not, introduce it in the utility extension and update all call sites accordingly.

🏁 Script executed:

#!/bin/bash
rg -n -C2 "activeDropbarExtensions" packages/editor
rg -n -C3 "addActiveDropbarExtension|removeActiveDropbarExtension" packages/editor

Length of output: 11357


Fix race when switching dropbars: implement per-instance tracking for activeDropbarExtensions

Utility currently stores activeDropbarExtensions as a simple array and add/remove only accept the extension key; the deferred removal via setTimeout can remove a key added by another instance (race). Implement per-instance ref-counting and update call sites.

  • Change storage to Map<TActiveDropbarExtensions, Set> (or equivalent ref-counting).
  • Change commands to accept instanceId (e.g. addActiveDropbarExtension(extension, instanceId) / removeActiveDropbarExtension(extension, instanceId)) and only remove the instanceId; delete the map entry only when its set is empty.
  • Replace setTimeout(...) removals with queueMicrotask(...) to reduce ordering surprises and ensure microtask ordering with React updates.
  • Add stable per-instance ids in components (useRef(Symbol(...))) and remove on unmount (useEffect cleanup).
  • Update consumers to query “any active dropbars” via the new storage shape (helper like hasActiveDropbars) instead of checking activeDropbarExtensions.length.

Call sites to update:

  • packages/editor/src/core/extensions/utility.ts (storage + command implementations)
  • packages/editor/src/core/helpers/editor-ref.ts
  • packages/editor/src/core/extensions/enter-key.ts
  • packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx
  • packages/editor/src/core/extensions/table/plugins/drag-handles/row/drag-handle.tsx
  • packages/editor/src/core/extensions/mentions/utils.ts
  • packages/editor/src/core/extensions/emoji/suggestion.ts
  • packages/editor/src/core/extensions/slash-commands/root.tsx
  • (Any other locations that push/splice activeDropbarExtensions)

Component-level change (example; requires command API change):

-    onOpenChange: (open) => {
-      setIsDropdownOpen(open);
-      if (open) {
-        editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
-      } else {
-        setTimeout(() => {
-          editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
-        }, 0);
-      }
-    },
+    onOpenChange: (open) => {
+      setIsDropdownOpen(open);
+      if (open) {
+        editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current);
+      } else {
+        queueMicrotask(() => {
+          editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current);
+        });
+      }
+    },

Also add in the component:

const instanceIdRef = useRef(Symbol("table-col-dropbar"));
useEffect(() => () => {
  try { editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE, instanceIdRef.current); } catch {}
}, [editor]);
🤖 Prompt for AI Agents
packages/editor/src/core/extensions/table/plugins/drag-handles/column/drag-handle.tsx
lines 64-73: the current add/remove of CORE_EXTENSIONS.TABLE uses a global array
and deferred setTimeout removal which can remove a key added by another instance
(race); replace this by giving each component a stable per-instance id
(useRef(Symbol("table-col-dropbar"))), pass that id into
editor.commands.addActiveDropbarExtension(extension, instanceId) and
removeActiveDropbarExtension(extension, instanceId), change the utility storage
to Map<ExtensionKey, Set<InstanceId>> (or ref-counting set) and only delete the
map entry when the set is empty, switch deferred removal to queueMicrotask(...)
instead of setTimeout, add a helper hasActiveDropbars() that checks map
non-emptiness and update all consumers listed in the review to use the new APIs,
and ensure the component removes its id on unmount via useEffect cleanup calling
removeActiveDropbarExtension inside a try/catch.

whileElementsMounted: autoUpdate,
});
const click = useClick(context);
Expand Down Expand Up @@ -185,7 +196,6 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
}}
lockScroll
/>

<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
Expand All @@ -195,7 +205,7 @@ export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
zIndex: 100,
}}
>
<ColumnOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
<ColumnOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
</div>
</FloatingPortal>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { Ellipsis } from "lucide-react";
import { useCallback, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import {
findTable,
Expand Down Expand Up @@ -59,7 +61,16 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
}),
],
open: isDropdownOpen,
onOpenChange: setIsDropdownOpen,
onOpenChange: (open) => {
setIsDropdownOpen(open);
if (open) {
editor.commands.addActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
} else {
setTimeout(() => {
editor.commands.removeActiveDropbarExtension(CORE_EXTENSIONS.TABLE);
}, 0);
}
},
whileElementsMounted: autoUpdate,
});
const click = useClick(context);
Expand Down Expand Up @@ -184,7 +195,6 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
}}
lockScroll
/>

<div
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
ref={refs.setFloating}
Expand All @@ -194,7 +204,7 @@ export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
zIndex: 100,
}}
>
<RowOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
<RowOptionsDropdown editor={editor} onClose={() => context.onOpenChange(false)} />
</div>
</FloatingPortal>
)}
Expand Down
25 changes: 22 additions & 3 deletions packages/editor/src/core/extensions/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types

import type { IEditorProps, TEditorAsset, TFileHandler } from "@/types";
type TActiveDropbarExtensions = CORE_EXTENSIONS.MENTION | CORE_EXTENSIONS.EMOJI | TAdditionalActiveDropbarExtensions;

type TActiveDropbarExtensions =
| CORE_EXTENSIONS.MENTION
| CORE_EXTENSIONS.EMOJI
| CORE_EXTENSIONS.SLASH_COMMANDS
| CORE_EXTENSIONS.TABLE
| TAdditionalActiveDropbarExtensions;

declare module "@tiptap/core" {
interface Commands {
utility: {
[CORE_EXTENSIONS.UTILITY]: {
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
updateAssetsList: (
args:
Expand All @@ -26,6 +31,8 @@ declare module "@tiptap/core" {
idToRemove: string;
}
) => () => void;
addActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
removeActiveDropbarExtension: (extension: TActiveDropbarExtensions) => () => void;
};
}
}
Expand Down Expand Up @@ -102,6 +109,18 @@ export const UtilityExtension = (props: Props) => {
}
this.storage.assetsList = Array.from(uniqueAssets);
},
addActiveDropbarExtension: (extension) => () => {
const index = this.storage.activeDropbarExtensions.indexOf(extension);
if (index === -1) {
this.storage.activeDropbarExtensions.push(extension);
}
},
removeActiveDropbarExtension: (extension) => () => {
const index = this.storage.activeDropbarExtensions.indexOf(extension);
if (index !== -1) {
this.storage.activeDropbarExtensions.splice(index, 1);
}
},
};
},
});
Expand Down
5 changes: 5 additions & 0 deletions packages/editor/src/core/helpers/editor-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
const markdownOutput = editor?.storage?.markdown?.getMarkdown?.();
return markdownOutput;
},
isAnyDropbarOpen: () => {
if (!editor) return false;
const utilityStorage = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY);
return utilityStorage.activeDropbarExtensions.length > 0;
},
scrollSummary: (marking) => {
if (!editor) return;
scrollSummary(editor, marking);
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/core/types/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export type EditorRefApi = {
getMarkDown: () => string;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
isAnyDropbarOpen: () => boolean;
isEditorReadyToDiscard: () => boolean;
isMenuItemActive: <T extends TEditorCommands>(props: TCommandWithPropsWithItemKey<T>) => boolean;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
Expand Down
Loading