Skip to content

Bug: NodeContextMenuPlugin key Prop Issue #7801

@Kepron

Description

@Kepron

I used the ContextMenu example from Playground in my own project. But I am getting the errors below. I tried a lot, but I couldn’t fix them.

Lexical version: 0.35

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 */

import type { JSX } from "react";
import { useMemo } from "react";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { NodeContextMenuOption, NodeContextMenuPlugin, NodeContextMenuSeparator } from "@lexical/react/LexicalNodeContextMenuPlugin";
import {
  $getSelection,
  $isDecoratorNode,
  $isNodeSelection,
  $isRangeSelection,
  COPY_COMMAND,
  CUT_COMMAND,
  PASTE_COMMAND,
  type LexicalNode,
} from "lexical";
import { Clipboard, ClipboardType, Copy, Link2Off, Scissors, Trash2 } from "lucide-react";

export function ContextMenuPlugin(): JSX.Element {
  const [editor] = useLexicalComposerContext();

  const items = useMemo(() => {
    return [
      new NodeContextMenuOption(`Remove Link`, {
        $onSelect: () => {
          editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
        },
        $showOn: (node: LexicalNode) => $isLinkNode(node.getParent()),
        disabled: false,
        icon: <Link2Off className="h-4 w-4" />,
      }),
      new NodeContextMenuSeparator({
        $showOn: (node: LexicalNode) => $isLinkNode(node.getParent()),
      }),
      new NodeContextMenuOption(`Cut`, {
        $onSelect: () => {
          editor.dispatchCommand(CUT_COMMAND, null);
        },
        disabled: false,
        icon: <Scissors className="h-4 w-4" />,
      }),
      new NodeContextMenuOption(`Copy`, {
        $onSelect: () => {
          editor.dispatchCommand(COPY_COMMAND, null);
        },
        disabled: false,
        icon: <Copy className="h-4 w-4" />,
      }),
      new NodeContextMenuOption(`Paste`, {
        $onSelect: () => {
          navigator.clipboard.read().then(async function (...args) {
            const data = new DataTransfer();

            const readClipboardItems = await navigator.clipboard.read();
            const item = readClipboardItems[0];

            const permission = await navigator.permissions.query({
              // @ts-expect-error These types are incorrect.
              name: "clipboard-read",
            });
            if (permission.state === "denied") {
              alert("Not allowed to paste from clipboard.");
              return;
            }

            for (const type of item.types) {
              const dataString = await (await item.getType(type)).text();
              data.setData(type, dataString);
            }

            const event = new ClipboardEvent("paste", {
              clipboardData: data,
            });

            editor.dispatchCommand(PASTE_COMMAND, event);
          });
        },
        disabled: false,
        icon: <Clipboard className="h-4 w-4" />,
      }),
      new NodeContextMenuOption(`Paste as Plain Text`, {
        $onSelect: () => {
          navigator.clipboard.read().then(async function (...args) {
            const permission = await navigator.permissions.query({
              // @ts-expect-error These types are incorrect.
              name: "clipboard-read",
            });

            if (permission.state === "denied") {
              alert("Not allowed to paste from clipboard.");
              return;
            }

            const data = new DataTransfer();
            const clipboardText = await navigator.clipboard.readText();
            data.setData("text/plain", clipboardText);

            const event = new ClipboardEvent("paste", {
              clipboardData: data,
            });
            editor.dispatchCommand(PASTE_COMMAND, event);
          });
        },
        disabled: false,
        icon: <ClipboardType className="h-4 w-4" />,
      }),
      new NodeContextMenuSeparator(),
      new NodeContextMenuOption(`Delete Node`, {
        $onSelect: () => {
          const selection = $getSelection();
          if ($isRangeSelection(selection)) {
            const currentNode = selection.anchor.getNode();
            const ancestorNodeWithRootAsParent = currentNode.getParents().at(-2);

            ancestorNodeWithRootAsParent?.remove();
          } else if ($isNodeSelection(selection)) {
            const selectedNodes = selection.getNodes();
            selectedNodes.forEach((node) => {
              if ($isDecoratorNode(node)) {
                node.remove();
              }
            });
          }
        },
        disabled: false,
        icon: <Trash2 className="h-4 w-4" />,
      }),
    ];
  }, [editor]);

  return (
    <NodeContextMenuPlugin
      className="bg-popover text-popover-foreground !z-50 overflow-hidden rounded-md border shadow-md outline-none [&:has(*)]:!z-10"
      itemClassName="relative w-full flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
      separatorClassName="bg-border -mx-1 h-px"
      items={items}
    />
  );
}
Image Image
A props object containing a "key" prop is being spread into JSX:
  let props = {key: someKey, className: ..., disabled: ..., icon: ..., label: ..., onClick: ..., title: ..., type: ..., onMouseUp: ..., ref: ..., tabIndex: ..., onFocus: ..., onMouseMove: ..., onPointerLeave: ...};
  <ForwardRef {...props} />
React keys must be passed directly to JSX without using spread:
  let props = {className: ..., disabled: ..., icon: ..., label: ..., onClick: ..., title: ..., type: ..., onMouseUp: ..., ref: ..., tabIndex: ..., onFocus: ..., onMouseMove: ..., onPointerLeave: ...};
  <ForwardRef key={someKey} {...props} />

components\editor\plugins\context-menu-plugin.tsx (139:5) @ ContextMenuPlugin


  137 |
  138 |   return (
> 139 |     <NodeContextMenuPlugin
      |     ^
  140 |       className="bg-popover text-popover-foreground !z-50 overflow-hidden rounded-md border shadow-md outline-none [&:has(*)]:!z-10"
  141 |       itemClassName="relative w-full flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
  142 |       separatorClassName="bg-border -mx-1 h-px"
A props object containing a "key" prop is being spread into JSX:
  let props = {key: someKey, className: ..., type: ..., ref: ..., tabIndex: ..., onFocus: ..., onClick: ..., onMouseMove: ..., onPointerLeave: ...};
  <ForwardRef {...props} />
React keys must be passed directly to JSX without using spread:
  let props = {className: ..., type: ..., ref: ..., tabIndex: ..., onFocus: ..., onClick: ..., onMouseMove: ..., onPointerLeave: ...};
  <ForwardRef key={someKey} {...props} />

components\editor\plugins\context-menu-plugin.tsx (139:5) @ ContextMenuPlugin


  137 |
  138 |   return (
> 139 |     <NodeContextMenuPlugin
      |     ^
  140 |       className="bg-popover text-popover-foreground !z-50 overflow-hidden rounded-md border shadow-md outline-none [&:has(*)]:!z-10"
  141 |       itemClassName="relative w-full flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
  142 |       separatorClassName="bg-border -mx-1 h-px"

package.json

{
  "name": "agencive-blog-main",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev -p 3120 -H blog.agencive.l",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@excalidraw/excalidraw": "^0.18.0",
    "@hookform/resolvers": "^5.2.1",
    "@lexical/code": "^0.35.0",
    "@lexical/file": "^0.35.0",
    "@lexical/hashtag": "^0.35.0",
    "@lexical/link": "^0.35.0",
    "@lexical/list": "^0.35.0",
    "@lexical/markdown": "^0.35.0",
    "@lexical/overflow": "^0.35.0",
    "@lexical/react": "^0.35.0",
    "@lexical/rich-text": "^0.35.0",
    "@lexical/selection": "^0.35.0",
    "@lexical/table": "^0.35.0",
    "@lexical/text": "^0.35.0",
    "@lexical/utils": "^0.35.0",
    "@radix-ui/react-icons": "^1.3.2",
    "@radix-ui/react-slot": "^1.2.3",
    "@radix-ui/react-visually-hidden": "^1.2.3",
    "@tabler/icons-react": "^3.34.1",
    "@tanstack/react-query": "^5.85.3",
    "@tanstack/react-table": "^8.21.3",
    "axios": "^1.11.0",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "cmdk": "^1.1.1",
    "cookies-next": "^6.1.0",
    "date-fns": "^4.1.0",
    "dayjs": "^1.11.13",
    "immer": "^10.1.1",
    "jose": "^6.0.12",
    "juice": "^11.0.1",
    "katex": "^0.16.22",
    "lexical": "^0.35.0",
    "lodash-es": "^4.17.21",
    "lucide-react": "^0.539.0",
    "next": "15.4.6",
    "next-themes": "^0.4.6",
    "notistack": "^3.0.2",
    "radix-ui": "^1.4.3",
    "react": "^19.1.1",
    "react-colorful": "^5.6.1",
    "react-day-picker": "^9.9.0",
    "react-dom": "^19.1.1",
    "react-error-boundary": "^6.0.0",
    "react-hook-form": "^7.62.0",
    "shadcn": "^2.10.0",
    "sonner": "^2.0.7",
    "tailwind-merge": "^3.3.1",
    "yup": "^1.7.0",
    "zod": "^3.25.76"
  },
  "devDependencies": {
    "@faker-js/faker": "^9.9.0",
    "@tailwindcss/postcss": "^4",
    "@types/lodash-es": "^4.17.12",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "tailwindcss": "^4",
    "tw-animate-css": "^1.3.6",
    "typescript": "^5"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2017",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./*"],
      "react": ["./node_modules/@types/react"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts",
    "main/types/*.d.ts",
    "scripts/build-registry.mts",
    "next.config.ts"
  ],
  "exclude": ["node_modules"]
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions